Refactor ArenaScene.js: modularize match runtime, spectator camera, and UI components
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user