feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화
- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침) - Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원) - 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일 - 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지) - 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가 - 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선 - 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
@@ -30,6 +30,7 @@ import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
|
||||
import { fighterManifest } from "../fighter/fighterManifest.js";
|
||||
import { pickFighters } from "../fighter/fighterSelection.js";
|
||||
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
||||
import { trackMatchFinish, trackMatchStart } from "../../ui/dailyMetrics.js";
|
||||
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
|
||||
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
|
||||
import {
|
||||
@@ -68,13 +69,14 @@ import {
|
||||
} from "../../ui/battleDeathNotice.js";
|
||||
|
||||
export class ArenaScene extends Phaser.Scene {
|
||||
constructor({ getInitialMatchConfig, setStatus }) {
|
||||
constructor({ getInitialMatchConfig, onMatchEnd, setStatus }) {
|
||||
super("arena");
|
||||
this.fighters = [];
|
||||
this.getInitialMatchConfig = getInitialMatchConfig;
|
||||
this.matchId = 0;
|
||||
this.matchOver = false;
|
||||
this.matchPaused = false;
|
||||
this.onMatchEnd = typeof onMatchEnd === "function" ? onMatchEnd : () => {};
|
||||
this.presentationMode = true;
|
||||
this.ready = false;
|
||||
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
|
||||
@@ -197,6 +199,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
|
||||
|
||||
if (!silent) {
|
||||
trackMatchStart();
|
||||
this.setStatus(matchStatusText(this.teams));
|
||||
} else {
|
||||
this.focusPresentationCombat();
|
||||
@@ -906,6 +909,10 @@ update(time) {
|
||||
}
|
||||
|
||||
finishMatch() {
|
||||
if (this.matchOver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
|
||||
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
|
||||
|
||||
@@ -934,6 +941,7 @@ update(time) {
|
||||
|
||||
this.clearBattleNotice();
|
||||
this.persistDailyDeathStats();
|
||||
trackMatchFinish();
|
||||
|
||||
if (livingTeams.size === 1) {
|
||||
const winningTeamId = Array.from(livingTeams)[0];
|
||||
@@ -942,5 +950,7 @@ update(time) {
|
||||
} else {
|
||||
this.setStatus("무승부!");
|
||||
}
|
||||
|
||||
this.onMatchEnd();
|
||||
}
|
||||
}
|
||||
|
||||
+38
-1
@@ -6,9 +6,11 @@ import {
|
||||
PRESENTATION_TEAM_SIZE,
|
||||
} from "./constants.js";
|
||||
import { createMatchForm } from "./ui/matchForm.js";
|
||||
import { createAboutDialog } from "./ui/aboutDialog.js";
|
||||
import { trackVisitor } from "./ui/visitorCounter.js";
|
||||
|
||||
const matchForm = createMatchForm();
|
||||
const aboutDialog = createAboutDialog();
|
||||
const appNode = document.querySelector("#app");
|
||||
const startButton = document.querySelector("#start-button");
|
||||
const drawer = document.querySelector("#fighter-entry");
|
||||
@@ -18,6 +20,7 @@ const drawerToggleButton = document.querySelector("#drawer-toggle");
|
||||
const playerNamesInput = document.querySelector("#player-names");
|
||||
const pauseButton = document.querySelector("#pause-button");
|
||||
const restartButton = document.querySelector("#restart-button");
|
||||
const MOBILE_MATCH_MEDIA_QUERY = "(max-width: 960px)";
|
||||
|
||||
function isMatchLive() {
|
||||
return appNode?.classList.contains("match-live") ?? false;
|
||||
@@ -26,6 +29,7 @@ function isMatchLive() {
|
||||
function openOptionsDrawer({ focus = true } = {}) {
|
||||
appNode?.classList.add("options-open");
|
||||
setDrawerCollapsed(false);
|
||||
resetDrawerScroll();
|
||||
drawer?.setAttribute("aria-hidden", "false");
|
||||
startButton?.setAttribute("aria-expanded", "true");
|
||||
|
||||
@@ -53,8 +57,15 @@ function startConfiguredMatch(matchConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
appNode?.classList.remove("match-ended");
|
||||
appNode?.classList.add("match-live");
|
||||
openOptionsDrawer({ focus: false });
|
||||
|
||||
if (shouldCompactOptionsDrawer()) {
|
||||
setDrawerCollapsed(true);
|
||||
} else {
|
||||
openOptionsDrawer({ focus: false });
|
||||
}
|
||||
|
||||
arenaScene.startMatch(matchConfig);
|
||||
syncPauseButton();
|
||||
}
|
||||
@@ -71,6 +82,11 @@ function setDrawerCollapsed(collapsed) {
|
||||
|
||||
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
|
||||
drawer?.setAttribute("aria-hidden", "false");
|
||||
|
||||
if (!nextCollapsed) {
|
||||
resetDrawerScroll();
|
||||
}
|
||||
|
||||
syncDrawerToggleButton();
|
||||
}
|
||||
|
||||
@@ -95,6 +111,22 @@ function syncPauseButton() {
|
||||
pauseButton.setAttribute("aria-pressed", String(isPaused));
|
||||
}
|
||||
|
||||
function shouldCompactOptionsDrawer() {
|
||||
return window.matchMedia?.(MOBILE_MATCH_MEDIA_QUERY).matches ?? window.innerWidth <= 960;
|
||||
}
|
||||
|
||||
function resetDrawerScroll() {
|
||||
if (drawer) {
|
||||
drawer.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMatchEnd() {
|
||||
appNode?.classList.add("match-ended");
|
||||
setDrawerCollapsed(true);
|
||||
syncPauseButton();
|
||||
}
|
||||
|
||||
function revealAppWhenStylesAreReady() {
|
||||
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
|
||||
const reveal = () => {
|
||||
@@ -127,12 +159,17 @@ restartButton?.addEventListener("click", () => {
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
if (aboutDialog?.isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeOptionsDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
const arenaScene = new ArenaScene({
|
||||
getInitialMatchConfig: getPresentationMatchConfig,
|
||||
onMatchEnd: handleMatchEnd,
|
||||
setStatus: matchForm.setStatus,
|
||||
});
|
||||
|
||||
|
||||
+469
-31
@@ -273,28 +273,47 @@ textarea:focus-visible {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
.arena-meta {
|
||||
position: fixed;
|
||||
right: clamp(10px, 2vw, 18px);
|
||||
bottom: clamp(10px, 2vw, 18px);
|
||||
z-index: 5;
|
||||
min-height: 26px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.visitor-count,
|
||||
.about-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(238 185 73 / 0.18);
|
||||
border: 1px solid rgb(238 185 73 / 0.22);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
background: rgb(8 10 7 / 0.58);
|
||||
padding: 5px 12px;
|
||||
background: rgb(8 10 7 / 0.68);
|
||||
color: #e7c879;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease;
|
||||
backdrop-filter: blur(8px);
|
||||
background 180ms ease,
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease,
|
||||
opacity 220ms ease;
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
#app.match-live .visitor-count {
|
||||
@@ -302,6 +321,19 @@ textarea:focus-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.about-button {
|
||||
min-width: 72px;
|
||||
color: #ffe8b4;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.about-button:hover {
|
||||
border-color: rgb(238 185 73 / 0.42);
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.start-button,
|
||||
form button[type="submit"],
|
||||
.pause-button,
|
||||
@@ -373,6 +405,10 @@ form button[type="submit"]:hover,
|
||||
color: #120f08;
|
||||
}
|
||||
|
||||
#app.match-ended .pause-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -549,6 +585,200 @@ h2 {
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
}
|
||||
|
||||
.about-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(16px, 4vw, 34px);
|
||||
background: rgb(3 5 4 / 0.66);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.about-backdrop[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.about-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100svh - 32px));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(239 199 103 / 0.28);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
|
||||
#11140f;
|
||||
box-shadow:
|
||||
0 24px 100px rgb(0 0 0 / 0.62),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.about-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
|
||||
}
|
||||
|
||||
.about-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgb(238 185 73 / 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
color: #f8deb0;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.about-close:hover {
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 0 clamp(20px, 4vw, 30px) 14px;
|
||||
border-bottom: 1px solid rgb(238 185 73 / 0.16);
|
||||
}
|
||||
|
||||
.about-tab {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgb(255 246 216 / 0.06);
|
||||
color: #ead8ad;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.about-tab[aria-selected="true"] {
|
||||
border-color: rgb(238 185 73 / 0.36);
|
||||
background: #323822;
|
||||
color: #fff7df;
|
||||
}
|
||||
|
||||
.about-panel {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
|
||||
}
|
||||
|
||||
.about-fields {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 50px;
|
||||
border-bottom: 1px solid rgb(238 185 73 / 0.14);
|
||||
}
|
||||
|
||||
.about-field-row:first-child {
|
||||
border-top: 1px solid rgb(238 185 73 / 0.14);
|
||||
}
|
||||
|
||||
.about-field-row dt {
|
||||
color: #e3b24f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.about-field-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #fff7df;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.about-field-row a,
|
||||
.about-markdown a {
|
||||
color: #85dcc7;
|
||||
text-decoration-color: rgb(133 220 199 / 0.42);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.about-markdown {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
color: #ead8ad;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.56;
|
||||
}
|
||||
|
||||
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-markdown h3,
|
||||
.about-markdown h4,
|
||||
.about-markdown h5,
|
||||
.about-markdown h6 {
|
||||
color: #fff3d2;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.about-markdown blockquote {
|
||||
border-left: 3px solid rgb(238 185 73 / 0.36);
|
||||
padding: 4px 0 4px 16px;
|
||||
color: #c4b693;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.about-markdown hr {
|
||||
margin: 8px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid rgb(238 185 73 / 0.16);
|
||||
}
|
||||
|
||||
.about-markdown code {
|
||||
border: 1px solid rgb(238 185 73 / 0.14);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
color: #f1c761;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.about-markdown li {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.about-markdown li strong {
|
||||
color: #fff3d2;
|
||||
}
|
||||
|
||||
.about-markdown ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.about-empty {
|
||||
color: #bfae83;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -749,8 +979,8 @@ input[type="range"] {
|
||||
|
||||
.team-score.is-focused {
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(255 244 209 / 0.92),
|
||||
0 0 24px rgb(227 178 79 / 0.34);
|
||||
inset 0 0 0 2px rgb(255 244 209 / 0.92),
|
||||
0 0 18px rgb(227 178 79 / 0.26);
|
||||
}
|
||||
|
||||
.team-score:disabled {
|
||||
@@ -1028,7 +1258,17 @@ input[type="range"] {
|
||||
inset: 0;
|
||||
background: rgb(4 6 4 / 0.2);
|
||||
isolation: isolate;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.victory-celebration.is-leaving {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.victory-celebration::before {
|
||||
@@ -1151,7 +1391,7 @@ input[type="range"] {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
animation: victory-message-pulse 1.1s 0.2s ease-out both;
|
||||
animation: victory-message-pulse 720ms 80ms ease-out both;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw .victory-banner {
|
||||
@@ -1374,7 +1614,7 @@ input[type="range"] {
|
||||
|
||||
@keyframes victory-message-pulse {
|
||||
from {
|
||||
opacity: 0;
|
||||
opacity: 0.72;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
58% {
|
||||
@@ -1405,6 +1645,13 @@ input[type="range"] {
|
||||
|
||||
#app {
|
||||
--arena-gap: 0px;
|
||||
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
|
||||
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
|
||||
--mobile-options-button-width: 54px;
|
||||
--mobile-options-gap: 8px;
|
||||
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
|
||||
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
|
||||
--score-band-height: 132px;
|
||||
--score-panel-left: 10px;
|
||||
--score-panel-width: calc(100vw - 20px);
|
||||
--score-rail-width: 0px;
|
||||
@@ -1415,8 +1662,8 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
#app.match-live #game {
|
||||
width: min(100vw, calc(100svh - var(--score-band-height)));
|
||||
height: min(100vw, calc(100svh - var(--score-band-height)));
|
||||
width: var(--mobile-game-size);
|
||||
height: var(--mobile-game-size);
|
||||
margin-top: var(--score-band-height);
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -1434,6 +1681,101 @@ input[type="range"] {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry {
|
||||
top: calc(10px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .fighter-entry {
|
||||
top: calc(22px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
left: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry h2 {
|
||||
font-size: clamp(1.45rem, 7vw, 1.8rem);
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry textarea {
|
||||
height: 112px;
|
||||
min-height: 112px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry fieldset {
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry form {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#app.match-live .entry-copy {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#app.match-live .eyebrow {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
#app.match-live label,
|
||||
#app.match-live .spawn-placement-label {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
#app.match-live input:not([type="range"]):not([type="radio"]),
|
||||
#app.match-live textarea {
|
||||
min-height: 40px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
#app.match-live textarea {
|
||||
padding-block: 9px;
|
||||
}
|
||||
|
||||
#app.match-live .team-size-number {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
#app.match-live .spawn-placement-option span {
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
#app.match-live .match-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#app.match-live .match-actions button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
#app.match-live .drawer-toggle {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .drawer-toggle {
|
||||
width: var(--mobile-options-button-width);
|
||||
min-width: var(--mobile-options-button-width);
|
||||
padding-inline: 6px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .drawer-toggle::before {
|
||||
content: "옵션";
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.battle-preview {
|
||||
opacity: 0.62;
|
||||
}
|
||||
@@ -1454,22 +1796,62 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.scoreboard {
|
||||
align-items: flex-start;
|
||||
top: 10px;
|
||||
left: var(--score-panel-left);
|
||||
width: var(--score-panel-width);
|
||||
max-height: calc(var(--score-band-height) - 20px);
|
||||
padding: 7px;
|
||||
max-height: calc(var(--score-band-height) - 12px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 9px min(148px, 38vw) 9px 9px;
|
||||
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .scoreboard {
|
||||
width: calc(
|
||||
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
|
||||
);
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
.score-side {
|
||||
gap: 5px;
|
||||
display: grid;
|
||||
grid-auto-columns: var(--mobile-team-card-width);
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(2, 48px);
|
||||
gap: 5px 5px;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgb(238 185 73 / 0.38);
|
||||
}
|
||||
|
||||
.team-score {
|
||||
width: 90px;
|
||||
min-height: 54px;
|
||||
padding: 7px 8px 6px;
|
||||
font-size: 0.72rem;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
height: 48px;
|
||||
gap: 3px;
|
||||
padding: 5px 6px;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.team-score-count {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.team-score.is-focused {
|
||||
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
|
||||
}
|
||||
|
||||
.battle-notice {
|
||||
@@ -1487,21 +1869,77 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.kill-log {
|
||||
bottom: 10px;
|
||||
top: var(--mobile-kill-log-top);
|
||||
bottom: auto;
|
||||
left: 10px;
|
||||
width: calc(100vw - 20px);
|
||||
max-height: 25vh;
|
||||
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#app.match-live .victory-celebration {
|
||||
padding:
|
||||
var(--score-band-height)
|
||||
14px
|
||||
min(30svh, 230px);
|
||||
}
|
||||
|
||||
.victory-banner {
|
||||
width: min(calc(100vw - 48px), 520px);
|
||||
min-height: 92px;
|
||||
padding: 1rem 1.1rem;
|
||||
font-size: clamp(1.35rem, 7vw, 2rem);
|
||||
}
|
||||
|
||||
.match-status {
|
||||
bottom: 10px;
|
||||
width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
.arena-meta {
|
||||
right: 10px;
|
||||
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
z-index: 10;
|
||||
gap: 8px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.about-button {
|
||||
min-width: 68px;
|
||||
min-height: 26px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.about-backdrop {
|
||||
align-items: end;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.about-dialog {
|
||||
width: 100%;
|
||||
max-height: calc(100svh - 24px);
|
||||
}
|
||||
|
||||
.about-header {
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
padding: 0 18px 12px;
|
||||
}
|
||||
|
||||
.about-panel {
|
||||
padding: 16px 18px 22px;
|
||||
}
|
||||
|
||||
.about-field-row {
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
min-height: 48px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
const ABOUT_ENDPOINT = "/api/about";
|
||||
|
||||
const DEFAULT_ABOUT_CONTENT = {
|
||||
developer: {
|
||||
alias: "horoli",
|
||||
email: "sunha321@gmail.com",
|
||||
github: "https://github.com/Horoli",
|
||||
},
|
||||
privacyPolicy: {
|
||||
markdown: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function createAboutDialog() {
|
||||
const openButton = document.querySelector("#about-button");
|
||||
const backdrop = document.querySelector("#about-dialog");
|
||||
const dialog = backdrop?.querySelector("[data-about-dialog]");
|
||||
const closeButton = backdrop?.querySelector("[data-about-close]");
|
||||
const tabs = [...(backdrop?.querySelectorAll("[data-about-tab]") ?? [])];
|
||||
const panels = [...(backdrop?.querySelectorAll("[data-about-panel]") ?? [])];
|
||||
const privacyContent = backdrop?.querySelector("#privacy-policy-content");
|
||||
|
||||
if (!openButton || !backdrop || !dialog || !closeButton || tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let lastFocusedElement = null;
|
||||
let loadPromise = null;
|
||||
let loaded = false;
|
||||
|
||||
renderAboutContent(DEFAULT_ABOUT_CONTENT);
|
||||
|
||||
openButton.addEventListener("click", () => {
|
||||
openDialog();
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
closeDialog();
|
||||
});
|
||||
backdrop.addEventListener("click", (event) => {
|
||||
if (event.target === backdrop) {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
dialog.addEventListener("keydown", trapFocus);
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key !== "Escape" || !isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeDialog();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
selectTab(tab.dataset.aboutTab, { focus: true });
|
||||
});
|
||||
});
|
||||
|
||||
function openDialog() {
|
||||
lastFocusedElement = document.activeElement;
|
||||
backdrop.hidden = false;
|
||||
document.body.classList.add("about-dialog-open");
|
||||
openButton.setAttribute("aria-expanded", "true");
|
||||
selectTab("developer");
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
dialog.focus();
|
||||
});
|
||||
|
||||
loadAboutContent();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
backdrop.hidden = true;
|
||||
document.body.classList.remove("about-dialog-open");
|
||||
openButton.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (lastFocusedElement instanceof HTMLElement) {
|
||||
lastFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !backdrop.hidden;
|
||||
}
|
||||
|
||||
function selectTab(tabName, { focus = false } = {}) {
|
||||
const nextTabName = tabName || "developer";
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const selected = tab.dataset.aboutTab === nextTabName;
|
||||
tab.setAttribute("aria-selected", String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
|
||||
if (selected && focus) {
|
||||
tab.focus();
|
||||
}
|
||||
});
|
||||
|
||||
panels.forEach((panel) => {
|
||||
panel.hidden = panel.dataset.aboutPanel !== nextTabName;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAboutContent() {
|
||||
if (loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loadPromise) {
|
||||
loadPromise = fetchAboutContent()
|
||||
.then((content) => {
|
||||
renderAboutContent(content);
|
||||
loaded = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(error);
|
||||
renderMarkdown(
|
||||
privacyContent,
|
||||
"",
|
||||
"개인정보처리방침을 불러오지 못했습니다.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
loadPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await loadPromise;
|
||||
}
|
||||
|
||||
function renderAboutContent(content) {
|
||||
const normalizedContent = normalizeAboutContent(content);
|
||||
setField("alias", normalizedContent.developer.alias);
|
||||
setField("email", normalizedContent.developer.email);
|
||||
setLinkField("github", normalizedContent.developer.github);
|
||||
renderMarkdown(privacyContent, normalizedContent.privacyPolicy.markdown);
|
||||
}
|
||||
|
||||
return {
|
||||
close: closeDialog,
|
||||
isOpen,
|
||||
open: openDialog,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAboutContent() {
|
||||
const response = await fetch(ABOUT_ENDPOINT, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`About content fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function setField(fieldName, value) {
|
||||
const field = document.querySelector(`[data-about-field="${fieldName}"]`);
|
||||
|
||||
if (field) {
|
||||
field.textContent = value || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function setLinkField(fieldName, value) {
|
||||
const field = document.querySelector(`[data-about-field="${fieldName}"]`);
|
||||
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.textContent = "";
|
||||
|
||||
if (!value) {
|
||||
field.textContent = "-";
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = value;
|
||||
link.rel = "noreferrer";
|
||||
link.target = "_blank";
|
||||
link.textContent = value;
|
||||
field.appendChild(link);
|
||||
}
|
||||
|
||||
function normalizeAboutContent(content = {}) {
|
||||
return {
|
||||
developer: {
|
||||
alias: stringValue(content?.developer?.alias, DEFAULT_ABOUT_CONTENT.developer.alias),
|
||||
email: stringValue(content?.developer?.email, DEFAULT_ABOUT_CONTENT.developer.email),
|
||||
github: stringValue(content?.developer?.github, DEFAULT_ABOUT_CONTENT.developer.github),
|
||||
},
|
||||
privacyPolicy: {
|
||||
markdown: stringValue(
|
||||
content?.privacyPolicy?.markdown,
|
||||
DEFAULT_ABOUT_CONTENT.privacyPolicy.markdown,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(container, markdown, emptyMessage = "개인정보처리방침이 아직 작성되지 않았습니다.") {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.textContent = "";
|
||||
|
||||
const text = String(markdown || "").trim();
|
||||
|
||||
if (!text) {
|
||||
const message = document.createElement("p");
|
||||
message.className = "about-empty";
|
||||
message.textContent = emptyMessage;
|
||||
container.appendChild(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
let paragraphLines = [];
|
||||
let list = null;
|
||||
let blockquote = null;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paragraph = document.createElement("p");
|
||||
appendInlineMarkdown(paragraph, paragraphLines.join(" "));
|
||||
(blockquote || container).appendChild(paragraph);
|
||||
paragraphLines = [];
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
list = null;
|
||||
};
|
||||
|
||||
const closeBlockquote = () => {
|
||||
blockquote = null;
|
||||
};
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Horizontal Rule
|
||||
if (/^(?:---|[*]{3}|_{3})$/.test(trimmed)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
container.appendChild(document.createElement("hr"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Headings
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
|
||||
const level = Math.min(heading[1].length + 2, 6);
|
||||
const headingNode = document.createElement(`h${level}`);
|
||||
appendInlineMarkdown(headingNode, heading[2]);
|
||||
container.appendChild(headingNode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
const bqMatch = /^>\s?(.*)$/.exec(line);
|
||||
if (bqMatch) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
if (!blockquote) {
|
||||
blockquote = document.createElement("blockquote");
|
||||
container.appendChild(blockquote);
|
||||
}
|
||||
const content = bqMatch[1].trim();
|
||||
if (content) {
|
||||
const p = document.createElement("p");
|
||||
appendInlineMarkdown(p, content);
|
||||
blockquote.appendChild(p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// List Item
|
||||
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
if (listItem) {
|
||||
flushParagraph();
|
||||
closeBlockquote();
|
||||
|
||||
if (!list) {
|
||||
list = document.createElement("ul");
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
const item = document.createElement("li");
|
||||
appendInlineMarkdown(item, listItem[1]);
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
} else {
|
||||
paragraphLines.push(trimmed);
|
||||
}
|
||||
});
|
||||
|
||||
flushParagraph();
|
||||
}
|
||||
|
||||
function appendInlineMarkdown(parent, text) {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
const html = escaped
|
||||
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/_([^_]+)_/g, "<em>$1</em>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)]+)\)/g,
|
||||
'<a href="$2" rel="noreferrer" target="_blank">$1</a>',
|
||||
);
|
||||
|
||||
parent.innerHTML = html;
|
||||
}
|
||||
|
||||
function trapFocus(event) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = [
|
||||
...event.currentTarget.querySelectorAll(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
].filter((element) => element.offsetParent !== null);
|
||||
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(...values) {
|
||||
const value = values.find((candidate) => typeof candidate === "string");
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const DAILY_METRIC_ENDPOINTS = {
|
||||
donationClicked: "/api/daily-metrics/donation-clicked",
|
||||
matchFinished: "/api/daily-metrics/match-finished",
|
||||
matchStarted: "/api/daily-metrics/match-started",
|
||||
};
|
||||
|
||||
export function trackMatchStart() {
|
||||
return postDailyMetric("matchStarted");
|
||||
}
|
||||
|
||||
export function trackMatchFinish() {
|
||||
return postDailyMetric("matchFinished");
|
||||
}
|
||||
|
||||
export function trackDonationClick() {
|
||||
return postDailyMetric("donationClicked");
|
||||
}
|
||||
|
||||
async function postDailyMetric(type) {
|
||||
const endpoint = DAILY_METRIC_ENDPOINTS[type];
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Daily metric update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
||||
const VICTORY_CONFETTI_COUNT = 40;
|
||||
const VICTORY_CELEBRATION_EXIT_MS = 260;
|
||||
const VICTORY_CELEBRATION_VISIBLE_MS = 5200;
|
||||
const VICTORY_FANFARE_NOTES = [
|
||||
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
||||
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
||||
@@ -34,8 +36,11 @@ export function createVictoryConfettiPiece(index) {
|
||||
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
|
||||
|
||||
let victoryAudioContext = null;
|
||||
let victoryDismissTimer = null;
|
||||
let victoryRemoveTimer = null;
|
||||
|
||||
export function removeVictoryCelebration() {
|
||||
clearVictoryCelebrationTimers();
|
||||
document.querySelector(".victory-celebration")?.remove();
|
||||
}
|
||||
|
||||
@@ -72,13 +77,45 @@ export function createVictoryCelebration(message) {
|
||||
|
||||
banner.appendChild(messageNode);
|
||||
celebration.append(rays, confetti, banner);
|
||||
celebration.addEventListener("click", () => {
|
||||
dismissVictoryCelebration(celebration);
|
||||
});
|
||||
celebrationHost.appendChild(celebration);
|
||||
scheduleVictoryCelebrationDismiss(celebration);
|
||||
|
||||
if (isVictory) {
|
||||
playVictoryFanfare();
|
||||
}
|
||||
}
|
||||
|
||||
function clearVictoryCelebrationTimers() {
|
||||
window.clearTimeout(victoryDismissTimer);
|
||||
window.clearTimeout(victoryRemoveTimer);
|
||||
victoryDismissTimer = null;
|
||||
victoryRemoveTimer = null;
|
||||
}
|
||||
|
||||
function scheduleVictoryCelebrationDismiss(celebration) {
|
||||
clearVictoryCelebrationTimers();
|
||||
|
||||
victoryDismissTimer = window.setTimeout(() => {
|
||||
dismissVictoryCelebration(celebration);
|
||||
}, VICTORY_CELEBRATION_VISIBLE_MS);
|
||||
}
|
||||
|
||||
function dismissVictoryCelebration(celebration) {
|
||||
if (!celebration?.isConnected || celebration.classList.contains("is-leaving")) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearVictoryCelebrationTimers();
|
||||
celebration.classList.add("is-leaving");
|
||||
victoryRemoveTimer = window.setTimeout(() => {
|
||||
celebration.remove();
|
||||
victoryRemoveTimer = null;
|
||||
}, VICTORY_CELEBRATION_EXIT_MS);
|
||||
}
|
||||
|
||||
export function primeVictoryFanfareAudio() {
|
||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user