Refactor ArenaScene.js: modularize match runtime, spectator camera, and UI components

This commit is contained in:
2026-05-23 01:32:11 +09:00
parent 25137cf26e
commit bb08d5cee1
8 changed files with 640 additions and 556 deletions
+99
View File
@@ -0,0 +1,99 @@
const KILL_LOG_LIMIT = 8;
export function resetKillLog(nodes) {
const { logNode, listNode } = nodes;
if (listNode) {
listNode.replaceChildren();
}
logNode?.classList.remove("has-entries");
logNode?.setAttribute("aria-hidden", "true");
}
export function appendKillLog(nodes, winner, defender) {
const { logNode, listNode } = nodes;
if (!logNode || !listNode) {
return;
}
const item = document.createElement("li");
const killer = killLogFighterParts(winner);
const victim = killLogFighterParts(defender);
const action = document.createElement("span");
const weapon = document.createElement("span");
const actionText = document.createElement("span");
item.className = "kill-log-item";
item.style.setProperty("--killer-color", winner.team?.color ?? "#e3b24f");
item.style.setProperty("--victim-color", defender.team?.color ?? "#e3b24f");
item.setAttribute(
"aria-label",
`${killer.teamLabel} ${killer.memberLabel} 처치 ${victim.teamLabel} ${victim.memberLabel}`,
);
action.className = "kill-log-action";
weapon.className = "kill-log-weapon";
weapon.setAttribute("aria-hidden", "true");
actionText.className = "kill-log-action-text";
actionText.textContent = "처치";
action.append(weapon, actionText);
item.append(
createKillLogFighterNode(killer, "killer"),
action,
createKillLogFighterNode(victim, "victim"),
);
listNode.append(item);
while (listNode.children.length > KILL_LOG_LIMIT) {
listNode.firstElementChild?.remove();
}
logNode.classList.add("has-entries");
logNode.setAttribute("aria-hidden", "false");
}
function createKillLogFighterNode(fighterParts, role) {
const container = document.createElement("span");
const avatar = document.createElement("span");
const copy = document.createElement("span");
const team = document.createElement("span");
const member = document.createElement("span");
container.className = `kill-log-fighter ${role}`;
avatar.className = "kill-log-avatar";
if (fighterParts.avatarUrl) {
avatar.style.backgroundImage = `url("${fighterParts.avatarUrl}")`;
}
avatar.setAttribute("aria-hidden", "true");
copy.className = "kill-log-copy";
team.className = "kill-log-team";
team.textContent = fighterParts.teamLabel;
member.className = "kill-log-member";
member.textContent = fighterParts.memberLabel;
copy.append(team, member);
container.append(avatar, copy);
return container;
}
function killLogFighterParts(fighter) {
return {
teamLabel: fighter?.team?.label ?? "Unknown",
memberLabel: fighter?.skin?.key ?? fighter?.skin?.label ?? fighter?.fighterName ?? "fighter",
avatarUrl: fighterSkinIdleUrl(fighter?.skin),
};
}
function fighterSkinIdleUrl(skin) {
const idleFile = skin?.animations?.idle?.file;
if (!skin?.assetRoot || !idleFile) {
return "";
}
return `${skin.assetRoot}/${idleFile}`;
}
+49
View File
@@ -0,0 +1,49 @@
export function updateScoreboard(
containerLeft,
containerRight,
teams,
fighters,
{ selectedFighterTeamId = null, onTeamClick = () => {} } = {},
) {
if (!containerLeft || !containerRight) {
return;
}
containerLeft.innerHTML = "";
containerRight.innerHTML = "";
teams.forEach((team) => {
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
const teamEl = document.createElement("button");
teamEl.className = "team-score";
teamEl.type = "button";
teamEl.disabled = aliveCount === 0;
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
teamEl.style.setProperty("--team-color", team.color);
teamEl.style.backgroundColor = `${team.color}33`;
teamEl.style.borderLeft = `4px solid ${team.color}`;
if (selectedFighterTeamId === team.id) {
teamEl.classList.add("is-focused");
}
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
labelEl.textContent = team.label;
const ruleEl = document.createElement("span");
ruleEl.className = "team-score-rule";
const countEl = document.createElement("span");
countEl.className = "team-score-count";
countEl.textContent = `${aliveCount}명`;
teamEl.addEventListener("click", () => {
onTeamClick(team.id);
});
teamEl.append(labelEl, ruleEl, countEl);
containerLeft.appendChild(teamEl);
});
}
+90
View File
@@ -0,0 +1,90 @@
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
const SPECIES_LABELS = {
bear: "곰",
human: "인간",
orc: "오크",
skeleton: "해골",
slime: "슬라임",
wolf: "늑대",
};
const SPECIES_SUBJECT_PARTICLES = {
bear: "이",
human: "이",
orc: "가",
skeleton: "이",
slime: "이",
wolf: "가",
};
const DEATH_NOTICE_TEMPLATES = [
"오늘만 해도 {species}{particle} 전투 중에 {count}명 사망했습니다.",
"{species}{particle} 오늘 {count}명째 경기장 바닥과 친해졌습니다.",
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
];
export const BATTLE_NOTICE_DELAY_MS = 5000;
export const BATTLE_NOTICE_VISIBLE_MS = 2000;
export const BATTLE_NOTICE_INTERVAL_MS = 10000;
export function createDeathCounts() {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = 0;
return counts;
}, {});
}
export function normalizeDeathCounts(value = {}) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
return counts;
}, {});
}
export function addDeathCounts(baseCounts, matchCounts) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
return counts;
}, {});
}
export function normalizeSpecies(value) {
return SPECIES_KEYS.includes(value) ? value : "human";
}
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
const topSpecies = SPECIES_KEYS
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
.sort((left, right) => right.count - left.count)[0];
if (!topSpecies || topSpecies.count === 0) {
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
}
const template = DEATH_NOTICE_TEMPLATES[
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
];
return template
.replace("{species}", SPECIES_LABELS[topSpecies.species])
.replace("{particle}", SPECIES_SUBJECT_PARTICLES[topSpecies.species])
.replace("{count}", topSpecies.count.toLocaleString("ko-KR"));
}
export function showBattleDeathNotice(noticeNode, message) {
if (!noticeNode) {
return;
}
noticeNode.textContent = message;
noticeNode.classList.add("is-visible");
noticeNode.setAttribute("aria-hidden", "false");
}
export function clearBattleNotice(noticeNode) {
if (!noticeNode) {
return;
}
noticeNode.classList.remove("is-visible");
noticeNode.setAttribute("aria-hidden", "true");
}
+131
View File
@@ -0,0 +1,131 @@
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
const VICTORY_CONFETTI_COUNT = 40;
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 },
{ duration: 0.18, frequency: 493.88, offset: 0.13, volume: 0.064 },
{ duration: 0.18, frequency: 659.25, offset: 0.13, volume: 0.05 },
{ duration: 0.2, frequency: 587.33, offset: 0.28, volume: 0.062 },
{ duration: 0.2, frequency: 783.99, offset: 0.28, volume: 0.048 },
{ duration: 0.5, frequency: 523.25, offset: 0.46, volume: 0.064 },
{ duration: 0.5, frequency: 659.25, offset: 0.46, volume: 0.052 },
{ duration: 0.5, frequency: 783.99, offset: 0.46, volume: 0.043 },
];
export function createVictoryConfettiPiece(index) {
const piece = document.createElement("i");
const angle = (Math.PI * 2 * index) / VICTORY_CONFETTI_COUNT + (index % 4) * 0.11;
const distance = 170 + (index % 8) * 26;
const x = Math.round(Math.cos(angle) * distance);
const y = Math.round(Math.sin(angle) * distance * 0.78);
piece.className = "victory-confetti-piece";
piece.style.setProperty("--confetti-color", VICTORY_CONFETTI_COLORS[index % VICTORY_CONFETTI_COLORS.length]);
piece.style.setProperty("--confetti-delay", `${(index % 10) * 18}ms`);
piece.style.setProperty("--confetti-duration", `${880 + (index % 6) * 90}ms`);
piece.style.setProperty("--confetti-spin", `${180 + (index % 9) * 58}deg`);
piece.style.setProperty("--confetti-x", `${x}px`);
piece.style.setProperty("--confetti-y", `${y}px`);
piece.style.setProperty("--confetti-tilt", `${(index % 7) * 19 - 54}deg`);
return piece;
}
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
let victoryAudioContext = null;
export function removeVictoryCelebration() {
document.querySelector(".victory-celebration")?.remove();
}
export function createVictoryCelebration(message) {
const celebrationHost = document.querySelector("#app") ?? document.querySelector(".arena-shell");
if (!celebrationHost) {
return;
}
const isVictory = message.includes("승리");
const celebration = document.createElement("div");
celebration.className = `victory-celebration ${isVictory ? "is-victory" : "is-draw"}`;
celebration.setAttribute("aria-hidden", "true");
const rays = document.createElement("span");
rays.className = "victory-rays";
const confetti = document.createElement("span");
confetti.className = "victory-confetti";
if (isVictory) {
Array.from({ length: VICTORY_CONFETTI_COUNT }, (_, index) => {
confetti.appendChild(createVictoryConfettiPiece(index));
});
}
const banner = document.createElement("div");
banner.className = "victory-banner";
const messageNode = document.createElement("span");
messageNode.className = "victory-banner-message";
messageNode.textContent = message;
banner.appendChild(messageNode);
celebration.append(rays, confetti, banner);
celebrationHost.appendChild(celebration);
if (isVictory) {
playVictoryFanfare();
}
}
export function primeVictoryFanfareAudio() {
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
if (!AudioContextClass) {
return null;
}
if (!victoryAudioContext) {
victoryAudioContext = new AudioContextClass();
}
if (victoryAudioContext.state === "suspended") {
victoryAudioContext.resume().catch(() => {});
}
return victoryAudioContext;
}
export function playVictoryFanfare() {
const audioContext = primeVictoryFanfareAudio();
if (!audioContext || audioContext.state !== "running") {
return;
}
const startAt = audioContext.currentTime + 0.03;
VICTORY_FANFARE_NOTES.forEach((note) => {
playVictoryFanfareNote(audioContext, startAt + note.offset, note);
});
}
function playVictoryFanfareNote(audioContext, startAt, { duration, frequency, volume }) {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const releaseAt = startAt + duration;
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(frequency, startAt);
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.01, releaseAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.025);
gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(startAt);
oscillator.stop(releaseAt + 0.02);
}