Add final combat polish and saved settings
This commit is contained in:
+243
-20
@@ -4,6 +4,8 @@ import {
|
||||
CAMERA_MAX_ZOOM,
|
||||
CAMERA_MIN_ZOOM,
|
||||
CAMERA_ZOOM_STEP,
|
||||
FINAL_COMBAT_SLOW_MOTION_DURATION,
|
||||
FINAL_COMBAT_SLOW_MOTION_SCALE,
|
||||
MINIMAP_ALPHA,
|
||||
MINIMAP_MARGIN,
|
||||
MINIMAP_VIEWPORT_SIZE,
|
||||
@@ -12,9 +14,12 @@ import {
|
||||
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";
|
||||
@@ -61,6 +66,11 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.battleDeathCounts = createDeathCounts();
|
||||
this.deathStatsBaseline = createDeathCounts();
|
||||
this.deathStatsSaved = false;
|
||||
this.finalFocusNextSwitchAt = 0;
|
||||
this.finalFocusTarget = null;
|
||||
this.spectatorMode = null;
|
||||
this.slowMotionRestoreState = null;
|
||||
this.slowMotionTimer = null;
|
||||
}
|
||||
|
||||
preload() {
|
||||
@@ -133,6 +143,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.matchId += 1;
|
||||
this.matchOver = false;
|
||||
this.setPaused(false, { silent: true });
|
||||
this.clearFinalCombatEffects();
|
||||
this.presentationMode = silent;
|
||||
this.resetMatchDeathStats({ silent });
|
||||
this.observedCombat = [];
|
||||
@@ -405,23 +416,13 @@ update(time) {
|
||||
}
|
||||
|
||||
// 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동
|
||||
const livingFighterCount = this.fighters.filter(isLivingFighter).length;
|
||||
const forcedSpectatorZoom = getForcedSpectatorZoom(livingFighterCount);
|
||||
const livingFighters = this.fighters.filter(isLivingFighter);
|
||||
const spectatorState = getSpectatorState(livingFighters);
|
||||
this.syncSpectatorMode(spectatorState?.mode ?? null);
|
||||
|
||||
if (forcedSpectatorZoom) {
|
||||
this.setMainCameraZoom(forcedSpectatorZoom);
|
||||
|
||||
const combatCenter = this.getObservedCombatCenter();
|
||||
if (combatCenter) {
|
||||
|
||||
// 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용
|
||||
const targetX = Math.round(combatCenter.x);
|
||||
const targetY = Math.round(combatCenter.y);
|
||||
|
||||
// Move from the current world-space camera center toward the target.
|
||||
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;
|
||||
}
|
||||
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);
|
||||
@@ -430,6 +431,143 @@ update(time) {
|
||||
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 (this.presentationMode || this.matchOver || this.matchPaused || !this.isFinalCombatActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.slowMotionRestoreState) {
|
||||
this.slowMotionRestoreState = {
|
||||
animations: this.anims?.globalTimeScale ?? 1,
|
||||
clock: this.time?.timeScale ?? 1,
|
||||
physics: this.physics?.world?.timeScale ?? 1,
|
||||
tweens: this.tweens?.timeScale ?? 1,
|
||||
};
|
||||
this.applySceneTimeScale(FINAL_COMBAT_SLOW_MOTION_SCALE);
|
||||
}
|
||||
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
}
|
||||
|
||||
this.slowMotionTimer = globalThis.setTimeout(() => {
|
||||
this.clearFinalCombatEffects();
|
||||
}, FINAL_COMBAT_SLOW_MOTION_DURATION);
|
||||
}
|
||||
|
||||
applySceneTimeScale(scale) {
|
||||
if (this.time) {
|
||||
this.time.timeScale = scale;
|
||||
}
|
||||
|
||||
if (this.physics?.world) {
|
||||
this.physics.world.timeScale = scale;
|
||||
}
|
||||
|
||||
if (this.tweens) {
|
||||
this.tweens.timeScale = scale;
|
||||
}
|
||||
|
||||
if (this.anims) {
|
||||
this.anims.globalTimeScale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
clearFinalCombatEffects() {
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
this.slowMotionTimer = null;
|
||||
}
|
||||
|
||||
if (this.slowMotionRestoreState) {
|
||||
const restore = this.slowMotionRestoreState;
|
||||
this.slowMotionRestoreState = null;
|
||||
|
||||
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;
|
||||
@@ -624,7 +762,7 @@ update(time) {
|
||||
|
||||
observeCombat(attacker, defender) {
|
||||
const canObserveCombat = Boolean(
|
||||
getForcedSpectatorZoom(this.fighters.filter(isLivingFighter).length),
|
||||
getSpectatorState(this.fighters.filter(isLivingFighter)),
|
||||
);
|
||||
|
||||
if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) {
|
||||
@@ -713,6 +851,7 @@ update(time) {
|
||||
}
|
||||
|
||||
this.matchOver = true;
|
||||
this.clearFinalCombatEffects();
|
||||
clearCombatObjects(this);
|
||||
this.fighters.forEach((fighter) => {
|
||||
if (fighter.body) {
|
||||
@@ -935,18 +1074,102 @@ function findClosestOpponentPair(fighters) {
|
||||
return closestPair;
|
||||
}
|
||||
|
||||
function getForcedSpectatorZoom(livingFighterCount) {
|
||||
function getSpectatorState(livingFighters) {
|
||||
const livingFighterCount = livingFighters.length;
|
||||
const teamSummaries = getLivingTeamSummaries(livingFighters);
|
||||
|
||||
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
||||
return SPECTATOR_FINAL_FIGHT_ZOOM;
|
||||
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 SPECTATOR_LATE_FIGHT_ZOOM;
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ATTACK_COOLDOWN,
|
||||
ATTACK_DAMAGE_MAX,
|
||||
ATTACK_DAMAGE_MIN,
|
||||
DEAD_FIGHTER_ALPHA,
|
||||
DEAD_FIGHTER_DEPTH,
|
||||
ATTACK_RANGE,
|
||||
FIGHTER_MAX_HP,
|
||||
FIGHTER_SCALE,
|
||||
@@ -80,6 +82,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||
time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker);
|
||||
attacker.isLocked = true;
|
||||
scene.observeCombat?.(attacker, defender);
|
||||
scene.triggerFinalCombatSlowMotion?.(attacker, defender, attack.animation);
|
||||
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
|
||||
|
||||
switch (getCombatType(attacker)) {
|
||||
@@ -349,6 +352,13 @@ function killFighter(defender, winner, onWinner) {
|
||||
defender.body.setVelocity(0, 0);
|
||||
defender.body.enable = false;
|
||||
defender.healthBar.width = 0;
|
||||
defender.setAlpha(DEAD_FIGHTER_ALPHA);
|
||||
defender.setDepth(DEAD_FIGHTER_DEPTH);
|
||||
defender.disableInteractive();
|
||||
defender.teamMarker?.setVisible(false);
|
||||
defender.nameLabel?.setVisible(false);
|
||||
defender.healthBack?.setVisible(false);
|
||||
defender.healthBar?.setVisible(false);
|
||||
playAnimation(defender, "death");
|
||||
winner.isLocked = false;
|
||||
winner.body.setVelocity(0, 0);
|
||||
|
||||
@@ -2,6 +2,7 @@ import Phaser from "phaser";
|
||||
import {
|
||||
FIGHTER_FRAME_HEIGHT,
|
||||
FIGHTER_FRAME_WIDTH,
|
||||
FIGHTER_DEPTH,
|
||||
FIGHTER_HITBOX_HEIGHT,
|
||||
FIGHTER_HITBOX_OFFSET_X,
|
||||
FIGHTER_HITBOX_OFFSET_Y,
|
||||
@@ -32,7 +33,8 @@ export function createFighter(
|
||||
|
||||
fighter.setScale(FIGHTER_SCALE);
|
||||
fighter.setName(displayName);
|
||||
fighter.setDepth(2);
|
||||
fighter.setDepth(FIGHTER_DEPTH);
|
||||
fighter.setAlpha(1);
|
||||
fighter.setCollideWorldBounds(true);
|
||||
fighter.setFlipX(faceLeft);
|
||||
fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT);
|
||||
@@ -109,6 +111,17 @@ export function createFighter(
|
||||
}
|
||||
|
||||
export function syncFighterHud(fighter) {
|
||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||
|
||||
fighter.nameLabel.setVisible(isVisible);
|
||||
fighter.healthBack.setVisible(isVisible);
|
||||
fighter.healthBar.setVisible(isVisible);
|
||||
syncTeamMarker(fighter);
|
||||
|
||||
if (!isVisible || !fighter.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
|
||||
const healthOffset = 44 * scaleRatio;
|
||||
const hitbox = fighter.body;
|
||||
@@ -119,7 +132,6 @@ export function syncFighterHud(fighter) {
|
||||
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
|
||||
syncTeamMarker(fighter);
|
||||
}
|
||||
|
||||
function syncTeamMarker(fighter) {
|
||||
|
||||
Reference in New Issue
Block a user