Add final combat polish and saved settings

This commit is contained in:
2026-05-22 18:00:23 +09:00
parent 0720efe7ba
commit a03dc02d01
8 changed files with 406 additions and 47 deletions
+33 -16
View File
@@ -17,6 +17,9 @@ export const ATTACK_DAMAGE_MAX = 24;
export const DEFAULT_TEAM_SIZE = 5;
// 캐릭터 스프라이트의 기본 화면 배율입니다.
export const FIGHTER_SCALE = 3;
export const FIGHTER_DEPTH = 2;
export const DEAD_FIGHTER_DEPTH = 1;
export const DEAD_FIGHTER_ALPHA = 0.42;
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
export const FIGHTER_FRAME_WIDTH = 100;
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
@@ -97,6 +100,11 @@ export const SPECTATOR_CAMERA_LERP = 0.1;
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
export const SPECTATOR_FINAL_TEAM_COUNT = 2;
export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8;
export const SPECTATOR_RANDOM_FOCUS_INTERVAL = 2400;
export const FINAL_COMBAT_SLOW_MOTION_DURATION = 520;
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.35;
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
@@ -168,11 +176,14 @@ export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
return TEAM_COLORS[safeIndex % TEAM_COLORS.length];
}
const hue = (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
const saturation = TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
const hue =
(TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
const saturation =
TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
const lightness =
TEAM_COLOR_LIGHTNESSES[
Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % TEAM_COLOR_LIGHTNESSES.length
Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) %
TEAM_COLOR_LIGHTNESSES.length
];
return hslToHex(hue, saturation, lightness);
@@ -181,23 +192,29 @@ export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
function hslToHex(hue, saturation, lightness) {
const normalizedSaturation = saturation / 100;
const normalizedLightness = lightness / 100;
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
const chroma =
(1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
const match = normalizedLightness - chroma / 2;
const [red, green, blue] = huePrime < 1
? [chroma, x, 0]
: huePrime < 2
? [x, chroma, 0]
: huePrime < 3
? [0, chroma, x]
: huePrime < 4
? [0, x, chroma]
: huePrime < 5
? [x, 0, chroma]
: [chroma, 0, x];
const [red, green, blue] =
huePrime < 1
? [chroma, x, 0]
: huePrime < 2
? [x, chroma, 0]
: huePrime < 3
? [0, chroma, x]
: huePrime < 4
? [0, x, chroma]
: huePrime < 5
? [x, 0, chroma]
: [chroma, 0, x];
return `#${[red, green, blue]
.map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, "0"))
.map((channel) =>
Math.round((channel + match) * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}
+243 -20
View File
@@ -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;
+10
View File
@@ -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);
+14 -2
View File
@@ -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) {
+70
View File
@@ -1,5 +1,10 @@
import { NICKNAME_LENGTH } from "../constants.js";
const STORAGE_KEYS = {
names: "arena.match.playerNames",
teamSize: "arena.match.teamSize",
};
export function createMatchForm() {
const form = getElement("#fighter-form");
const namesInput = getElement("#player-names");
@@ -14,9 +19,14 @@ export function createMatchForm() {
teamSize: Number(teamSizeInput.value),
});
restoreSavedMatchSettings(namesInput, teamSizeInput);
syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
namesInput.addEventListener("input", () => {
saveMatchSettings(namesInput, teamSizeInput);
});
teamSizeInput.addEventListener("input", () => {
syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
saveMatchSettings(namesInput, teamSizeInput);
});
return {
@@ -62,3 +72,63 @@ function nicknameValues(value) {
function syncTeamSizeOutput(input, output) {
output.textContent = input.value;
}
function restoreSavedMatchSettings(namesInput, teamSizeInput) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
const savedNames = storage.getItem(STORAGE_KEYS.names);
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
if (savedNames !== null) {
namesInput.value = savedNames;
}
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
if (normalizedTeamSize) {
teamSizeInput.value = normalizedTeamSize;
}
} catch {
// Storage may be unavailable in private or restricted browser contexts.
}
}
function saveMatchSettings(namesInput, teamSizeInput) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEYS.names, namesInput.value);
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
} catch {
// Ignore storage failures so the match form remains usable.
}
}
function normalizeTeamSize(value, input) {
const min = Number(input.min) || 1;
const max = Number(input.max) || min;
const teamSize = Math.round(Number(value));
if (!Number.isFinite(teamSize)) {
return "";
}
return String(Math.min(max, Math.max(min, teamSize)));
}
function getLocalStorage() {
try {
return window.localStorage;
} catch {
return null;
}
}