import Phaser from "phaser"; import { ARENA_SIZE, CAMERA_MAX_ZOOM, CAMERA_MIN_ZOOM, CAMERA_ZOOM_STEP, FINAL_COMBAT_SLOW_MOTION_ENABLED, FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION, FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION, FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION, FINAL_COMBAT_SLOW_MOTION_SCALE, MINIMAP_ALPHA, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEW_FRAME_STROKE, SELECTED_FIGHTER_CAMERA_ZOOM, SPECTATOR_CAMERA_LERP, SPECTATOR_FINAL_FIGHTER_THRESHOLD, SPECTATOR_FINAL_TEAM_COUNT, SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD, SPECTATOR_FINAL_FIGHT_ZOOM, SPECTATOR_LATE_FIGHTER_THRESHOLD, SPECTATOR_LATE_FIGHT_ZOOM, SPECTATOR_RANDOM_FOCUS_INTERVAL, } from "../constants.js"; import { drawArena } from "./arenaRenderer.js"; import { clearCombatObjects, updateFighter } from "./combat.js"; import { createFighterAnimations, preloadFighterSheets } from "./fighterAssets.js"; import { createFighter, syncFighterHud } from "./fighterFactory.js"; 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 }) { super("arena"); this.fighters = []; this.getInitialMatchConfig = getInitialMatchConfig; this.matchId = 0; this.matchOver = false; this.matchPaused = false; this.presentationMode = true; this.ready = false; this.updateStatus = typeof setStatus === "function" ? setStatus : () => {}; this.setStatus = (message) => { this.updateStatus(message); removeVictoryCelebration(); if (message.includes("승리") || message.includes("무승부")) { createVictoryCelebration(message); } }; this.observedCombat = []; this.selectedFighter = null; this.teams = []; this.killLogNode = null; this.killLogListNode = null; this.battleNoticeHideTimer = null; this.battleNoticeNode = null; this.battleNoticeSequence = 0; this.battleNoticeTimer = null; this.battleDeathCounts = createDeathCounts(); this.deathStatsBaseline = createDeathCounts(); this.deathStatsSaved = false; this.finalFocusNextSwitchAt = 0; this.finalFocusTarget = null; this.spectatorMode = null; this.slowMotionRestoreState = null; this.slowMotionTimer = null; this.slowMotionTransitionFrame = null; } preload() { preloadFighterSheets(this, fighterManifest); } create() { this.physics.world.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE); this.cameras.main.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE); this.cameras.main.setBackgroundColor("#282819"); drawArena(this); createFighterAnimations(this, fighterManifest); // 미니맵 카메라 설정 this.minimapCamera = this.cameras .add(MINIMAP_MARGIN, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE) .setZoom(MINIMAP_VIEWPORT_SIZE / ARENA_SIZE) .setName("minimap"); this.minimapCamera.setBackgroundColor(0x000000); this.minimapCamera.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); this.minimapViewportFrame = this.add.graphics().setDepth(10); this.cameras.main.ignore(this.minimapViewportFrame); this.updateMinimapViewportFrame(); this.minimapCamera.setAlpha(0); // 기본적으로는 숨김 // 마우스 휠로 줌 조절 this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => { const newZoom = Phaser.Math.Clamp( this.cameras.main.zoom + (deltaY > 0 ? -CAMERA_ZOOM_STEP : CAMERA_ZOOM_STEP), CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM, ); this.setMainCameraZoom(newZoom); // 확대 시 미니맵 표시 }); this.input.on("gameobjectdown", (pointer, gameObject) => { if (this.fighters.includes(gameObject)) { this.selectFighter(gameObject); } }); this.input.on("pointerdown", (pointer, gameObjects = []) => { if (!gameObjects.some((gameObject) => this.fighters.includes(gameObject))) { this.clearSelectedFighter(); } }); this.input.keyboard?.on("keydown-ESC", () => { this.clearSelectedFighter(); }); this.ready = true; this.startMatch(this.getInitialMatchConfig(), { silent: true }); } startMatch({ names = [], spawnPlacement, teamSize } = {}, { silent = false } = {}) { if (!this.ready) { return; } if (names.length < 2) { this.setStatus("참가자 닉네임을 2명 이상 입력하세요."); return; } if (!silent) { primeVictoryFanfareAudio(); } const matchSetup = createMatchSetup(names, teamSize, spawnPlacement); const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length); const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, { expandSpawnMultipliers: !silent, }); syncTeamSizes(matchSetup.teams, fighterPlans); this.matchId += 1; this.matchOver = false; this.setPaused(false, { silent: true }); this.clearFinalCombatEffects(); this.presentationMode = silent; this.resetMatchDeathStats({ silent }); this.observedCombat = []; this.clearSelectedFighter(); this.setMainCameraZoom(CAMERA_MIN_ZOOM); this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); clearCombatObjects(this); this.fighters.forEach((fighter) => fighter.destroy()); this.resetKillLog(); this.teams = matchSetup.teams; this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan)); if (!silent) { this.setStatus(matchStatusText(this.teams)); } else { this.focusPresentationCombat(); } this.updateScoreboard(); } spawnSplitFighters(source, splitOnDeath) { const count = Math.max(0, Math.round(splitOnDeath.count ?? 0)); const childMaxHp = Math.max(1, Math.round(splitOnDeath.childMaxHp ?? 1)); if (count === 0) { return []; } const children = Array.from({ length: count }, (_, index) => { const position = clusterSpawnPosition(source, index, count); return createFighter(this, { canSplitOnDeath: Boolean(splitOnDeath.childCanSplit), faceLeft: source.flipX, hp: childMaxHp, maxHp: childMaxHp, name: source.name, skin: source.skin, team: source.team, teamIndex: source.teamIndex, x: position.x, y: position.y, }); }); this.fighters.push(...children); const team = this.teams.find((candidate) => candidate.id === source.team.id); if (team) { team.size += children.length; } return children; } resetKillLog() { resetKillLog(this.getKillLogNodes()); } resetMatchDeathStats({ silent = false } = {}) { this.clearBattleNotice(); this.battleDeathCounts = createDeathCounts(); this.battleNoticeSequence = 0; this.deathStatsBaseline = createDeathCounts(); this.deathStatsSaved = false; if (!silent) { this.loadTodayDeathStats(); this.scheduleBattleNotice(); } } loadTodayDeathStats() { const activeMatchId = this.matchId; fetchTodayDeathStats() .then((stats) => { if (this.matchId !== activeMatchId || this.presentationMode) { return; } this.deathStatsBaseline = normalizeDeathCounts(stats?.deathsBySpecies); }) .catch((error) => { console.warn(error); }); } scheduleBattleNotice(delayMs = BATTLE_NOTICE_DELAY_MS) { this.battleNoticeTimer?.remove(false); this.battleNoticeTimer = this.time.delayedCall(delayMs, () => { this.battleNoticeTimer = null; if (!this.matchOver && !this.presentationMode) { this.showBattleDeathNotice(); } }); } clearBattleNotice() { this.battleNoticeTimer?.remove(false); this.battleNoticeHideTimer?.remove(false); this.battleNoticeTimer = null; this.battleNoticeHideTimer = null; clearBattleNotice(this.getBattleNoticeNode()); } recordDeath(fighter) { if (this.presentationMode) { return; } const species = normalizeSpecies(fighter?.skin?.species); this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + 1; } showBattleDeathNotice() { const noticeNode = this.getBattleNoticeNode(); if (!noticeNode) { return; } const message = createDeathNoticeMessage( addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts), this.matchId + this.battleNoticeSequence, ); this.battleNoticeSequence += 1; showBattleDeathNotice(noticeNode, message); this.battleNoticeHideTimer?.remove(false); this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => { this.battleNoticeHideTimer = null; clearBattleNotice(noticeNode); if (!this.matchOver && !this.presentationMode) { this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS); } }); } getBattleNoticeNode() { this.battleNoticeNode ??= document.getElementById("battle-notice"); return this.battleNoticeNode; } persistDailyDeathStats() { if (this.deathStatsSaved || this.presentationMode) { return; } this.deathStatsSaved = true; addTodayDeathStats({ deathsBySpecies: { ...this.battleDeathCounts }, }) .then((result) => { this.deathStatsBaseline = normalizeDeathCounts(result?.today?.deathsBySpecies); }) .catch((error) => { console.warn(error); }); } recordKill(winner, defender) { if (this.presentationMode) { return; } this.recordDeath(defender); appendKillLog(this.getKillLogNodes(), winner, defender); } getKillLogNodes() { this.killLogNode ??= document.getElementById("kill-log"); this.killLogListNode ??= document.getElementById("kill-log-list"); return { logNode: this.killLogNode, listNode: this.killLogListNode, }; } update(time) { this.fighters.forEach(syncFighterHud); if (this.matchPaused) { this.updateMinimapViewportFrame(); return; } if (!this.matchOver) { this.fighters.forEach((fighter) => { updateFighter(this, fighter, time, () => { this.updateScoreboard(); this.finishMatch(); }); }); } if (this.presentationMode) { this.followPresentationCombat(); this.minimapCamera?.setAlpha(0); this.updateMinimapViewportFrame(); return; } if (this.focusSelectedFighter()) { this.updateMinimapViewportFrame(); return; } if (this.matchOver) { this.updateMinimapViewportFrame(); return; } // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 const livingFighters = this.fighters.filter(isLivingFighter); const spectatorState = getSpectatorState(livingFighters); this.syncSpectatorMode(spectatorState?.mode ?? null); if (spectatorState) { this.setMainCameraZoom(spectatorState.zoom); this.moveCameraToward(this.getSpectatorCameraTarget(spectatorState, livingFighters, time)); } else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) { // 줌이 1일 때는 경기장 중앙에 고정 this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); } this.updateMinimapViewportFrame(); } syncSpectatorMode(mode) { if (this.spectatorMode === mode) { return; } this.spectatorMode = mode; this.finalFocusNextSwitchAt = 0; this.finalFocusTarget = null; if (mode !== "final-random") { this.observedCombat = []; } } getSpectatorCameraTarget(spectatorState, livingFighters, time) { if (spectatorState.mode === "final-random") { return this.getRandomFinalFocusTarget(livingFighters, time); } if (spectatorState.mode === "final-underdog" && spectatorState.teamId) { const underdogFighters = livingFighters.filter( (fighter) => fighter.team.id === spectatorState.teamId, ); return averageFighterPosition(underdogFighters) ?? this.getObservedCombatCenter(); } return this.getObservedCombatCenter(); } getRandomFinalFocusTarget(livingFighters, time) { const candidates = livingFighters.filter(isLivingFighter); if (candidates.length === 0) { this.finalFocusTarget = null; return null; } const shouldPickNext = !isLivingFighter(this.finalFocusTarget) || time >= this.finalFocusNextSwitchAt; if (shouldPickNext) { const nextCandidates = candidates.length > 1 ? candidates.filter((fighter) => fighter !== this.finalFocusTarget) : candidates; this.finalFocusTarget = nextCandidates[Phaser.Math.Between(0, nextCandidates.length - 1)]; this.finalFocusNextSwitchAt = time + SPECTATOR_RANDOM_FOCUS_INTERVAL; } return fighterCameraPoint(this.finalFocusTarget); } moveCameraToward(target) { if (!target) { return; } const targetX = Math.round(target.x); const targetY = Math.round(target.y); this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP; this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP; } isFinalCombatActive() { return Boolean(getSpectatorState(this.fighters.filter(isLivingFighter))?.isFinal); } triggerFinalCombatSlowMotion() { if ( !FINAL_COMBAT_SLOW_MOTION_ENABLED || this.presentationMode || this.matchOver || this.matchPaused || !this.isFinalCombatActive() ) { return; } if (this.slowMotionRestoreState) { return; } this.slowMotionRestoreState = { animations: this.anims?.globalTimeScale ?? 1, clock: this.time?.timeScale ?? 1, physics: this.physics?.world?.timeScale ?? 1, tweens: this.tweens?.timeScale ?? 1, }; this.transitionSceneTimeScale( FINAL_COMBAT_SLOW_MOTION_SCALE, FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION, easeOutCubic, () => this.holdFinalCombatSlowMotion(), ); } holdFinalCombatSlowMotion() { if (!this.slowMotionRestoreState) { return; } this.slowMotionTimer = globalThis.setTimeout(() => { this.slowMotionTimer = null; this.releaseFinalCombatSlowMotion(); }, FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION); } releaseFinalCombatSlowMotion() { const restore = this.slowMotionRestoreState; if (!restore) { return; } this.transitionSceneTimeScale( restore.clock, FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION, easeInOutCubic, () => { if (this.slowMotionRestoreState !== restore) { return; } this.slowMotionRestoreState = null; this.restoreSceneTimeScale(restore); }, ); } transitionSceneTimeScale(targetScale, duration, ease, onComplete) { this.cancelSlowMotionTransition(); const startScale = this.time?.timeScale ?? 1; if (duration <= 0 || Math.abs(startScale - targetScale) < 0.001) { this.applySceneTimeScale(targetScale); onComplete?.(); return; } const startedAt = globalThis.performance.now(); const updateScale = (now) => { const progress = Math.min(1, Math.max(0, (now - startedAt) / duration)); this.applySceneTimeScale(startScale + (targetScale - startScale) * ease(progress)); if (progress >= 1) { this.slowMotionTransitionFrame = null; onComplete?.(); return; } this.slowMotionTransitionFrame = globalThis.requestAnimationFrame(updateScale); }; this.slowMotionTransitionFrame = globalThis.requestAnimationFrame(updateScale); } applySceneTimeScale(scale) { if (this.time) { this.time.timeScale = scale; } if (this.physics?.world) { // Arcade Physics uses larger timeScale values for slower world steps. this.physics.world.timeScale = arcadePhysicsTimeScale(scale); } if (this.tweens) { this.tweens.timeScale = scale; } if (this.anims) { this.anims.globalTimeScale = scale; } } clearFinalCombatEffects() { this.clearSlowMotionTimer(); this.cancelSlowMotionTransition(); if (this.slowMotionRestoreState) { const restore = this.slowMotionRestoreState; this.slowMotionRestoreState = null; this.restoreSceneTimeScale(restore); } } clearSlowMotionTimer() { if (this.slowMotionTimer) { globalThis.clearTimeout(this.slowMotionTimer); this.slowMotionTimer = null; } } cancelSlowMotionTransition() { if (this.slowMotionTransitionFrame !== null) { globalThis.cancelAnimationFrame(this.slowMotionTransitionFrame); this.slowMotionTransitionFrame = null; } } restoreSceneTimeScale(restore) { if (this.time) { this.time.timeScale = restore.clock; } if (this.physics?.world) { this.physics.world.timeScale = restore.physics; } if (this.tweens) { this.tweens.timeScale = restore.tweens; } if (this.anims) { this.anims.globalTimeScale = restore.animations; } } selectFighter(fighter) { if (!isLivingFighter(fighter)) { return; } if (this.selectedFighter === fighter) { this.clearSelectedFighter(); return; } this.clearSelectedFighter(); this.selectedFighter = fighter; fighter.isSelected = true; this.observedCombat = []; this.setMainCameraZoom(Math.max(this.cameras.main.zoom, SELECTED_FIGHTER_CAMERA_ZOOM)); this.centerCameraOnFighter(fighter); syncFighterHud(fighter); } clearSelectedFighter() { if (this.selectedFighter) { this.selectedFighter.isSelected = false; syncFighterHud(this.selectedFighter); } this.selectedFighter = null; } focusSelectedFighter() { if (!this.selectedFighter) { return false; } if (!isLivingFighter(this.selectedFighter)) { this.clearSelectedFighter(); return false; } this.centerCameraOnFighter(this.selectedFighter); return true; } centerCameraOnFighter(fighter) { const target = fighter.body?.center ?? fighter; this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y)); } isMatchPaused() { return this.matchPaused; } togglePause() { return this.setPaused(!this.matchPaused); } setPaused(paused, { silent = false } = {}) { const nextPaused = Boolean(paused) && this.ready && !this.matchOver && !this.presentationMode; if (this.matchPaused === nextPaused) { return this.matchPaused; } this.matchPaused = nextPaused; if (nextPaused) { this.physics.pause(); this.time.paused = true; this.tweens.pauseAll?.(); } else { this.physics.resume(); this.time.paused = false; this.tweens.resumeAll?.(); } this.setSceneAnimationsPaused(nextPaused); if (!silent && !this.presentationMode) { this.setStatus(nextPaused ? "일시정지" : matchStatusText(this.teams)); } return this.matchPaused; } setSceneAnimationsPaused(paused) { const animatedObjects = [ ...this.fighters, ...(this.combatObjects ? Array.from(this.combatObjects) : []), ]; animatedObjects.forEach((object) => { if (!object?.anims) { return; } if (paused) { object.anims.pause(); } else { object.anims.resume(); } }); } selectRandomTeamFighter(teamId) { if (this.matchOver) { return; } const candidates = this.fighters.filter( (fighter) => isLivingFighter(fighter) && fighter.team.id === teamId, ); if (candidates.length === 0) { return; } const fighter = candidates[Phaser.Math.Between(0, candidates.length - 1)]; this.selectFighter(fighter); this.setStatus(`${fighter.team.label} 시점: ${fighter.fighterName ?? fighter.name}`); this.updateScoreboard(); } focusPresentationCombat() { this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM); this.observedCombat = findClosestOpponentPair(this.fighters) ?? []; const combatCenter = this.getObservedCombatCenter(); if (combatCenter) { this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y)); } this.minimapCamera?.setAlpha(0); this.updateMinimapViewportFrame(); } followPresentationCombat() { if (this.cameras.main.zoom !== SPECTATOR_LATE_FIGHT_ZOOM) { this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM); } const combatCenter = this.getObservedCombatCenter(); if (!combatCenter) { return; } this.cameras.main.scrollX += (Math.round(combatCenter.x) - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP; this.cameras.main.scrollY += (Math.round(combatCenter.y) - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP; } setMainCameraZoom(zoom) { const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM); this.cameras.main.setZoom(newZoom); if (newZoom === CAMERA_MIN_ZOOM) { this.observedCombat = []; } this.minimapCamera.setAlpha(newZoom > CAMERA_MIN_ZOOM ? MINIMAP_ALPHA : 0); this.updateMinimapViewportFrame(); } updateMinimapViewportFrame() { if (!this.minimapViewportFrame) { return; } const camera = this.cameras.main; this.minimapViewportFrame.clear(); this.minimapViewportFrame.setVisible(camera.zoom > CAMERA_MIN_ZOOM); if (camera.zoom <= CAMERA_MIN_ZOOM) { return; } const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA_SIZE)); const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA_SIZE)); const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX; const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY; const cameraMidX = scrollX + camera.width / 2; const cameraMidY = scrollY + camera.height / 2; const frameX = Phaser.Math.Clamp( this.snapMinimapFrameValue(cameraMidX - frameWidth / 2), 0, ARENA_SIZE - frameWidth, ); const frameY = Phaser.Math.Clamp( this.snapMinimapFrameValue(cameraMidY - frameHeight / 2), 0, ARENA_SIZE - frameHeight, ); this.drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight); } drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight) { const stroke = Math.min(MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight); const sideHeight = Math.max(0, frameHeight - stroke * 2); this.minimapViewportFrame.fillStyle(0xffe4a8, 1); this.minimapViewportFrame.fillRect(frameX, frameY, frameWidth, stroke); this.minimapViewportFrame.fillRect(frameX, frameY + frameHeight - stroke, frameWidth, stroke); this.minimapViewportFrame.fillRect(frameX, frameY + stroke, stroke, sideHeight); this.minimapViewportFrame.fillRect( frameX + frameWidth - stroke, frameY + stroke, stroke, sideHeight, ); } snapMinimapFrameValue(value) { const minimapZoom = this.minimapCamera?.zoom ?? 1; return Math.round(value * minimapZoom) / minimapZoom; } observeCombat(attacker, defender) { const canObserveCombat = Boolean( getSpectatorState(this.fighters.filter(isLivingFighter)), ); if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) { return; } if ( !isLivingOpponentPair(this.observedCombat) || this.observedCombat.includes(attacker) || this.observedCombat.includes(defender) ) { this.observedCombat = [attacker, defender]; } } getObservedCombatCenter() { if (!isLivingOpponentPair(this.observedCombat)) { this.observedCombat = findClosestOpponentPair(this.fighters) ?? []; } if (!isLivingOpponentPair(this.observedCombat)) { return null; } const [fighterA, fighterB] = this.observedCombat; return { x: (fighterA.x + fighterB.x) / 2, y: (fighterA.y + fighterB.y) / 2, }; } updateScoreboard() { updateScoreboard( document.getElementById("score-left"), document.getElementById("score-right"), this.teams, this.fighters, { selectedFighterTeamId: this.selectedFighter?.team.id, onTeamClick: (teamId) => this.selectRandomTeamFighter(teamId), }, ); } finishMatch() { const livingFighters = this.fighters.filter((fighter) => !fighter.isDead); const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id)); if (livingTeams.size > 1) { return; } this.matchOver = true; this.clearFinalCombatEffects(); clearCombatObjects(this); this.fighters.forEach((fighter) => { if (fighter.body) { fighter.body.setVelocity(0, 0); } }); if (this.presentationMode) { const finishedMatchId = this.matchId; this.time.delayedCall(1200, () => { if (this.presentationMode && this.matchId === finishedMatchId) { this.startMatch(this.getInitialMatchConfig(), { silent: true }); } }); return; } this.clearBattleNotice(); this.persistDailyDeathStats(); if (livingTeams.size === 1) { const winningTeamId = Array.from(livingTeams)[0]; const winningTeam = this.teams.find((team) => team.id === winningTeamId); this.setStatus(`${winningTeam?.label ?? "Unknown"} 승리!`); } else { this.setStatus("무승부!"); } } }