diff --git a/src/game/ArenaScene.js b/src/game/ArenaScene.js index 02d2b52..ace47e2 100644 --- a/src/game/ArenaScene.js +++ b/src/game/ArenaScene.js @@ -31,6 +31,41 @@ import { fighterManifest } from "./fighterManifest.js"; import { pickFighters } from "./fighterSelection.js"; import { createMatchSetup, matchStatusText } from "./matchSetup.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 { constructor({ getInitialMatchConfig, setStatus }) { @@ -206,14 +241,7 @@ export class ArenaScene extends Phaser.Scene { } resetKillLog() { - const { logNode, listNode } = this.getKillLogNodes(); - - if (listNode) { - listNode.replaceChildren(); - } - - logNode?.classList.remove("has-entries"); - logNode?.setAttribute("aria-hidden", "true"); + resetKillLog(this.getKillLogNodes()); } resetMatchDeathStats({ silent = false } = {}) { @@ -262,10 +290,7 @@ export class ArenaScene extends Phaser.Scene { this.battleNoticeTimer = null; this.battleNoticeHideTimer = null; - const noticeNode = this.getBattleNoticeNode(); - - noticeNode?.classList.remove("is-visible"); - noticeNode?.setAttribute("aria-hidden", "true"); + clearBattleNotice(this.getBattleNoticeNode()); } recordDeath(fighter) { @@ -284,19 +309,17 @@ export class ArenaScene extends Phaser.Scene { return; } - noticeNode.textContent = createDeathNoticeMessage( + const message = createDeathNoticeMessage( addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts), this.matchId + this.battleNoticeSequence, ); this.battleNoticeSequence += 1; - noticeNode.classList.add("is-visible"); - noticeNode.setAttribute("aria-hidden", "false"); + showBattleDeathNotice(noticeNode, message); this.battleNoticeHideTimer?.remove(false); this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => { this.battleNoticeHideTimer = null; - noticeNode.classList.remove("is-visible"); - noticeNode.setAttribute("aria-hidden", "true"); + clearBattleNotice(noticeNode); if (!this.matchOver && !this.presentationMode) { this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS); @@ -333,48 +356,7 @@ export class ArenaScene extends Phaser.Scene { } this.recordDeath(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"); + appendKillLog(this.getKillLogNodes(), winner, defender); } getKillLogNodes() { @@ -911,50 +893,16 @@ update(time) { } updateScoreboard() { - const scoreLeft = document.getElementById("score-left"); - const scoreRight = document.getElementById("score-right"); - - if (!scoreLeft || !scoreRight) return; - - scoreLeft.innerHTML = ""; - scoreRight.innerHTML = ""; - - 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); - }); + updateScoreboard( + document.getElementById("score-left"), + document.getElementById("score-right"), + this.teams, + this.fighters, + { + selectedFighterTeamId: this.selectedFighter?.team.id, + onTeamClick: (teamId) => this.selectRandomTeamFighter(teamId), + }, + ); } 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; -} diff --git a/src/game/arenaFinalCombatEffects.js b/src/game/arenaFinalCombatEffects.js new file mode 100644 index 0000000..ee29254 --- /dev/null +++ b/src/game/arenaFinalCombatEffects.js @@ -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); +} diff --git a/src/game/arenaMatchRuntime.js b/src/game/arenaMatchRuntime.js new file mode 100644 index 0000000..0fde7bd --- /dev/null +++ b/src/game/arenaMatchRuntime.js @@ -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; + }); +} diff --git a/src/game/arenaSpectatorCamera.js b/src/game/arenaSpectatorCamera.js new file mode 100644 index 0000000..1ddac03 --- /dev/null +++ b/src/game/arenaSpectatorCamera.js @@ -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; +} diff --git a/src/ui/arenaKillLog.js b/src/ui/arenaKillLog.js new file mode 100644 index 0000000..c4e591e --- /dev/null +++ b/src/ui/arenaKillLog.js @@ -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}`; +} diff --git a/src/ui/arenaScoreboard.js b/src/ui/arenaScoreboard.js new file mode 100644 index 0000000..94c5a93 --- /dev/null +++ b/src/ui/arenaScoreboard.js @@ -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); + }); +} diff --git a/src/ui/battleDeathNotice.js b/src/ui/battleDeathNotice.js new file mode 100644 index 0000000..850b604 --- /dev/null +++ b/src/ui/battleDeathNotice.js @@ -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"); +} diff --git a/src/ui/victoryCelebration.js b/src/ui/victoryCelebration.js new file mode 100644 index 0000000..3ae3d2d --- /dev/null +++ b/src/ui/victoryCelebration.js @@ -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); +}