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

This commit is contained in:
Horoli 2026-05-23 01:32:11 +09:00
parent 25137cf26e
commit bb08d5cee1
8 changed files with 640 additions and 556 deletions

View File

@ -31,6 +31,41 @@ import { fighterManifest } from "./fighterManifest.js";
import { pickFighters } from "./fighterSelection.js"; import { pickFighters } from "./fighterSelection.js";
import { createMatchSetup, matchStatusText } from "./matchSetup.js"; import { createMatchSetup, matchStatusText } from "./matchSetup.js";
import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js"; import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js";
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "./arenaMatchRuntime.js";
import {
createDeathCounts,
normalizeDeathCounts,
addDeathCounts,
normalizeSpecies,
createDeathNoticeMessage,
} from "../ui/battleDeathNotice.js";
import {
getSpectatorState,
averageFighterPosition,
fighterCameraPoint,
findClosestOpponentPair,
isLivingOpponentPair,
isLivingFighter,
} from "./arenaSpectatorCamera.js";
import {
easeOutCubic,
easeInOutCubic,
arcadePhysicsTimeScale,
} from "./arenaFinalCombatEffects.js";
import {
createVictoryCelebration,
primeVictoryFanfareAudio,
removeVictoryCelebration,
} from "../ui/victoryCelebration.js";
import { updateScoreboard } from "../ui/arenaScoreboard.js";
import { appendKillLog, resetKillLog } from "../ui/arenaKillLog.js";
import {
BATTLE_NOTICE_DELAY_MS,
BATTLE_NOTICE_INTERVAL_MS,
BATTLE_NOTICE_VISIBLE_MS,
clearBattleNotice,
showBattleDeathNotice,
} from "../ui/battleDeathNotice.js";
export class ArenaScene extends Phaser.Scene { export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) { constructor({ getInitialMatchConfig, setStatus }) {
@ -206,14 +241,7 @@ export class ArenaScene extends Phaser.Scene {
} }
resetKillLog() { resetKillLog() {
const { logNode, listNode } = this.getKillLogNodes(); resetKillLog(this.getKillLogNodes());
if (listNode) {
listNode.replaceChildren();
}
logNode?.classList.remove("has-entries");
logNode?.setAttribute("aria-hidden", "true");
} }
resetMatchDeathStats({ silent = false } = {}) { resetMatchDeathStats({ silent = false } = {}) {
@ -262,10 +290,7 @@ export class ArenaScene extends Phaser.Scene {
this.battleNoticeTimer = null; this.battleNoticeTimer = null;
this.battleNoticeHideTimer = null; this.battleNoticeHideTimer = null;
const noticeNode = this.getBattleNoticeNode(); clearBattleNotice(this.getBattleNoticeNode());
noticeNode?.classList.remove("is-visible");
noticeNode?.setAttribute("aria-hidden", "true");
} }
recordDeath(fighter) { recordDeath(fighter) {
@ -284,19 +309,17 @@ export class ArenaScene extends Phaser.Scene {
return; return;
} }
noticeNode.textContent = createDeathNoticeMessage( const message = createDeathNoticeMessage(
addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts), addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
this.matchId + this.battleNoticeSequence, this.matchId + this.battleNoticeSequence,
); );
this.battleNoticeSequence += 1; this.battleNoticeSequence += 1;
noticeNode.classList.add("is-visible"); showBattleDeathNotice(noticeNode, message);
noticeNode.setAttribute("aria-hidden", "false");
this.battleNoticeHideTimer?.remove(false); this.battleNoticeHideTimer?.remove(false);
this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => { this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
this.battleNoticeHideTimer = null; this.battleNoticeHideTimer = null;
noticeNode.classList.remove("is-visible"); clearBattleNotice(noticeNode);
noticeNode.setAttribute("aria-hidden", "true");
if (!this.matchOver && !this.presentationMode) { if (!this.matchOver && !this.presentationMode) {
this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS); this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
@ -333,48 +356,7 @@ export class ArenaScene extends Phaser.Scene {
} }
this.recordDeath(defender); this.recordDeath(defender);
appendKillLog(this.getKillLogNodes(), winner, defender);
const { logNode, listNode } = this.getKillLogNodes();
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");
} }
getKillLogNodes() { getKillLogNodes() {
@ -911,50 +893,16 @@ update(time) {
} }
updateScoreboard() { updateScoreboard() {
const scoreLeft = document.getElementById("score-left"); updateScoreboard(
const scoreRight = document.getElementById("score-right"); document.getElementById("score-left"),
document.getElementById("score-right"),
if (!scoreLeft || !scoreRight) return; this.teams,
this.fighters,
scoreLeft.innerHTML = ""; {
scoreRight.innerHTML = ""; selectedFighterTeamId: this.selectedFighter?.team.id,
onTeamClick: (teamId) => this.selectRandomTeamFighter(teamId),
this.teams.forEach((team) => { },
const aliveCount = this.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 (this.selectedFighter?.team.id === 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", () => {
this.selectRandomTeamFighter(team.id);
});
teamEl.append(labelEl, ruleEl, countEl);
scoreLeft.appendChild(teamEl);
});
} }
finishMatch() { finishMatch() {
@ -996,456 +944,3 @@ update(time) {
} }
} }
} }
const KILL_LOG_LIMIT = 8;
const BATTLE_NOTICE_DELAY_MS = 5000;
const BATTLE_NOTICE_VISIBLE_MS = 2000;
const BATTLE_NOTICE_INTERVAL_MS = 10000;
const SPAWN_CLUSTER_MARGIN = 48;
const SPAWN_CLUSTER_STEP = 28;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
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}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
];
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 },
];
let victoryAudioContext = null;
function removeVictoryCelebration() {
document.querySelector(".victory-celebration")?.remove();
}
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();
}
}
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;
}
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;
}
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);
}
function easeOutCubic(progress) {
return 1 - (1 - progress) ** 3;
}
function easeInOutCubic(progress) {
if (progress < 0.5) {
return 4 * progress ** 3;
}
return 1 - ((-2 * progress + 2) ** 3) / 2;
}
function arcadePhysicsTimeScale(sceneTimeScale) {
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
}
function createDeathCounts() {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = 0;
return counts;
}, {});
}
function normalizeDeathCounts(value = {}) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
return counts;
}, {});
}
function addDeathCounts(baseCounts, matchCounts) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
return counts;
}, {});
}
function normalizeSpecies(value) {
return SPECIES_KEYS.includes(value) ? value : "human";
}
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"));
}
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}`;
}
function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
return fighterSetups.flatMap((fighterSetup, index) => {
const skin = skins[index];
const spawnMultiplier = expandSpawnMultipliers
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
: 1;
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
return {
...fighterSetup,
skin,
x: position.x,
y: position.y,
};
});
});
}
function clusterSpawnPosition(origin, index, count) {
if (count <= 1 || index === 0) {
return {
x: origin.x,
y: origin.y,
};
}
const ring = Math.ceil(index / 6);
const radius = SPAWN_CLUSTER_STEP * ring;
const angle = index * GOLDEN_ANGLE;
return {
x: clampInsideArena(origin.x + Math.cos(angle) * radius),
y: clampInsideArena(origin.y + Math.sin(angle) * radius),
};
}
function clampInsideArena(value) {
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
}
function syncTeamSizes(teams, fighterPlans) {
teams.forEach((team) => {
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
});
}
function findClosestOpponentPair(fighters) {
let closestPair;
let closestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((fighter, index) => {
if (!isLivingFighter(fighter)) {
return;
}
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
const candidate = fighters[candidateIndex];
if (!isLivingOpponentPair([fighter, candidate])) {
continue;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
if (distance < closestDistance) {
closestDistance = distance;
closestPair = [fighter, candidate];
}
}
});
return closestPair;
}
function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return {
isFinal: true,
mode: "final-random",
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
) {
return {
isFinal: true,
mode: "final-underdog",
teamId: getUnderdogTeamId(teamSummaries),
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return {
isFinal: false,
mode: "late",
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
};
}
return null;
}
function getLivingTeamSummaries(livingFighters) {
const summaries = new Map();
livingFighters.forEach((fighter) => {
const teamId = fighter.team.id;
const summary = summaries.get(teamId) ?? {
count: 0,
teamId,
};
summary.count += 1;
summaries.set(teamId, summary);
});
return Array.from(summaries.values());
}
function getUnderdogTeamId(teamSummaries) {
const sortedTeams = [...teamSummaries].sort((left, right) => left.count - right.count);
if (sortedTeams.length < 2 || sortedTeams[0].count === sortedTeams[1].count) {
return null;
}
return sortedTeams[0].teamId;
}
function averageFighterPosition(fighters) {
if (fighters.length === 0) {
return null;
}
const total = fighters.reduce(
(position, fighter) => {
const point = fighterCameraPoint(fighter);
position.x += point.x;
position.y += point.y;
return position;
},
{ x: 0, y: 0 },
);
return {
x: total.x / fighters.length,
y: total.y / fighters.length,
};
}
function fighterCameraPoint(fighter) {
const target = fighter?.body?.center ?? fighter;
if (!target) {
return null;
}
return {
x: target.x,
y: target.y,
};
}
function isLivingOpponentPair(pair) {
if (pair.length !== 2) {
return false;
}
const [fighterA, fighterB] = pair;
return (
isLivingFighter(fighterA) &&
isLivingFighter(fighterB) &&
fighterA.team.id !== fighterB.team.id
);
}
function isLivingFighter(fighter) {
return fighter?.active && !fighter.isDead;
}

View File

@ -0,0 +1,15 @@
export function easeOutCubic(progress) {
return 1 - (1 - progress) ** 3;
}
export function easeInOutCubic(progress) {
if (progress < 0.5) {
return 4 * progress ** 3;
}
return 1 - ((-2 * progress + 2) ** 3) / 2;
}
export function arcadePhysicsTimeScale(sceneTimeScale) {
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
}

View File

@ -0,0 +1,54 @@
import Phaser from "phaser";
import { ARENA_SIZE } from "../constants.js";
const SPAWN_CLUSTER_MARGIN = 48;
const SPAWN_CLUSTER_STEP = 28;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
return fighterSetups.flatMap((fighterSetup, index) => {
const skin = skins[index];
const spawnMultiplier = expandSpawnMultipliers
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
: 1;
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
return {
...fighterSetup,
skin,
x: position.x,
y: position.y,
};
});
});
}
export function clusterSpawnPosition(origin, index, count) {
if (count <= 1 || index === 0) {
return {
x: origin.x,
y: origin.y,
};
}
const ring = Math.ceil(index / 6);
const radius = SPAWN_CLUSTER_STEP * ring;
const angle = index * GOLDEN_ANGLE;
return {
x: clampInsideArena(origin.x + Math.cos(angle) * radius),
y: clampInsideArena(origin.y + Math.sin(angle) * radius),
};
}
export function clampInsideArena(value) {
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
}
export function syncTeamSizes(teams, fighterPlans) {
teams.forEach((team) => {
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
});
}

View File

@ -0,0 +1,151 @@
import Phaser from "phaser";
import {
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
SPECTATOR_FINAL_FIGHT_ZOOM,
SPECTATOR_FINAL_TEAM_COUNT,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD,
SPECTATOR_LATE_FIGHTER_THRESHOLD,
SPECTATOR_LATE_FIGHT_ZOOM,
} from "../constants.js";
export function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return {
isFinal: true,
mode: "final-random",
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
) {
return {
isFinal: true,
mode: "final-underdog",
teamId: getUnderdogTeamId(teamSummaries),
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return {
isFinal: false,
mode: "late",
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
};
}
return null;
}
export function getLivingTeamSummaries(livingFighters) {
const summaries = new Map();
livingFighters.forEach((fighter) => {
const teamId = fighter.team.id;
const summary = summaries.get(teamId) ?? {
count: 0,
teamId,
};
summary.count += 1;
summaries.set(teamId, summary);
});
return Array.from(summaries.values());
}
export function getUnderdogTeamId(teamSummaries) {
const sortedTeams = [...teamSummaries].sort((left, right) => left.count - right.count);
if (sortedTeams.length < 2 || sortedTeams[0].count === sortedTeams[1].count) {
return null;
}
return sortedTeams[0].teamId;
}
export function averageFighterPosition(fighters) {
if (fighters.length === 0) {
return null;
}
const total = fighters.reduce(
(position, fighter) => {
const point = fighterCameraPoint(fighter);
position.x += point.x;
position.y += point.y;
return position;
},
{ x: 0, y: 0 },
);
return {
x: total.x / fighters.length,
y: total.y / fighters.length,
};
}
export function fighterCameraPoint(fighter) {
const target = fighter?.body?.center ?? fighter;
if (!target) {
return null;
}
return {
x: target.x,
y: target.y,
};
}
export function findClosestOpponentPair(fighters) {
let closestPair;
let closestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((fighter, index) => {
if (!isLivingFighter(fighter)) {
return;
}
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
const candidate = fighters[candidateIndex];
if (!isLivingOpponentPair([fighter, candidate])) {
continue;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
if (distance < closestDistance) {
closestDistance = distance;
closestPair = [fighter, candidate];
}
}
});
return closestPair;
}
export function isLivingOpponentPair(pair) {
if (pair.length !== 2) {
return false;
}
const [fighterA, fighterB] = pair;
return (
isLivingFighter(fighterA) &&
isLivingFighter(fighterB) &&
fighterA.team.id !== fighterB.team.id
);
}
export function isLivingFighter(fighter) {
return fighter?.active && !fighter.isDead;
}

99
src/ui/arenaKillLog.js Normal file
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
src/ui/arenaScoreboard.js Normal file
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);
});
}

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");
}

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);
}