947 lines
26 KiB
JavaScript
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("무승부!");
|
|
}
|
|
}
|
|
}
|