arena/src/game/ArenaScene.js

947 lines
26 KiB
JavaScript

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("무승부!");
}
}
}