Add critical hits, victory celebrations, and enhanced match settings
This commit is contained in:
+21
-5
@@ -15,6 +15,17 @@ export const ATTACK_DAMAGE_MIN = 14;
|
||||
export const ATTACK_DAMAGE_MAX = 24;
|
||||
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
|
||||
export const DEFAULT_TEAM_SIZE = 5;
|
||||
// 전투 시작 시 전투원을 배치하는 기본 방식입니다.
|
||||
export const DEFAULT_SPAWN_PLACEMENT = "random";
|
||||
// 전투 설정 UI와 매치 생성 로직이 공유하는 스폰 배치 모드입니다.
|
||||
export const SPAWN_PLACEMENTS = {
|
||||
RANDOM: DEFAULT_SPAWN_PLACEMENT,
|
||||
STARTING_ZONES: "starting-zones",
|
||||
};
|
||||
// 최초 접속 대기 전투에서 고정으로 보여줄 팀 수입니다.
|
||||
export const PRESENTATION_TEAM_COUNT = 10;
|
||||
// 최초 접속 대기 전투에서 팀마다 배치할 전투원 수입니다.
|
||||
export const PRESENTATION_TEAM_SIZE = 5;
|
||||
// 캐릭터 스프라이트의 기본 화면 배율입니다.
|
||||
export const FIGHTER_SCALE = 3;
|
||||
export const FIGHTER_DEPTH = 2;
|
||||
@@ -90,9 +101,7 @@ export const MINIMAP_ALPHA = 0.8;
|
||||
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
||||
// 미니맵의 고정 픽셀 크기입니다.
|
||||
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
||||
// 미니맵 현재 뷰포트 표시용 바깥 윤곽선 두께입니다.
|
||||
export const MINIMAP_VIEW_FRAME_OUTLINE = 18;
|
||||
// 미니맵 현재 뷰포트 표시용 안쪽 선 두께입니다.
|
||||
// 미니맵 현재 뷰포트 표시용 선 두께입니다.
|
||||
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
||||
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
|
||||
export const SPECTATOR_CAMERA_LERP = 0.1;
|
||||
@@ -103,8 +112,15 @@ 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 FINAL_COMBAT_SLOW_MOTION_ENABLED = false;
|
||||
// 최종교전 공격 시작에서 슬로우 배율로 내려가는 속도 램프 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION = 14000;
|
||||
// 최종교전 공격을 슬로우 배율로 붙잡아 두는 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION = 14000;
|
||||
// 최종교전 슬로우에서 기본 속도로 복귀하는 속도 램프 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION = 14000;
|
||||
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.28;
|
||||
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
|
||||
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
|
||||
+314
-52
@@ -4,12 +4,14 @@ import {
|
||||
CAMERA_MAX_ZOOM,
|
||||
CAMERA_MIN_ZOOM,
|
||||
CAMERA_ZOOM_STEP,
|
||||
FINAL_COMBAT_SLOW_MOTION_DURATION,
|
||||
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_OUTLINE,
|
||||
MINIMAP_VIEW_FRAME_STROKE,
|
||||
SELECTED_FIGHTER_CAMERA_ZOOM,
|
||||
SPECTATOR_CAMERA_LERP,
|
||||
@@ -44,14 +46,10 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.setStatus = (message) => {
|
||||
this.updateStatus(message);
|
||||
|
||||
const oldBanner = document.querySelector(".victory-banner");
|
||||
if (oldBanner) oldBanner.remove();
|
||||
removeVictoryCelebration();
|
||||
|
||||
if (message.includes("승리") || message.includes("무승부")) {
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "victory-banner";
|
||||
banner.textContent = message;
|
||||
document.querySelector(".arena-shell")?.appendChild(banner);
|
||||
createVictoryCelebration(message);
|
||||
}
|
||||
};
|
||||
this.observedCombat = [];
|
||||
@@ -71,6 +69,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.spectatorMode = null;
|
||||
this.slowMotionRestoreState = null;
|
||||
this.slowMotionTimer = null;
|
||||
this.slowMotionTransitionFrame = null;
|
||||
}
|
||||
|
||||
preload() {
|
||||
@@ -125,7 +124,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.startMatch(this.getInitialMatchConfig(), { silent: true });
|
||||
}
|
||||
|
||||
startMatch({ names = [], teamSize } = {}, { silent = false } = {}) {
|
||||
startMatch({ names = [], spawnPlacement, teamSize } = {}, { silent = false } = {}) {
|
||||
if (!this.ready) {
|
||||
return;
|
||||
}
|
||||
@@ -135,9 +134,15 @@ export class ArenaScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchSetup = createMatchSetup(names, teamSize);
|
||||
if (!silent) {
|
||||
primeVictoryFanfareAudio();
|
||||
}
|
||||
|
||||
const matchSetup = createMatchSetup(names, teamSize, spawnPlacement);
|
||||
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
|
||||
expandSpawnMultipliers: !silent,
|
||||
});
|
||||
syncTeamSizes(matchSetup.teams, fighterPlans);
|
||||
|
||||
this.matchId += 1;
|
||||
@@ -354,7 +359,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
weapon.className = "kill-log-weapon";
|
||||
weapon.setAttribute("aria-hidden", "true");
|
||||
actionText.className = "kill-log-action-text";
|
||||
actionText.textContent = "처치 >";
|
||||
actionText.textContent = "처치";
|
||||
action.append(weapon, actionText);
|
||||
|
||||
item.append(
|
||||
@@ -499,27 +504,95 @@ update(time) {
|
||||
}
|
||||
|
||||
triggerFinalCombatSlowMotion() {
|
||||
if (this.presentationMode || this.matchOver || this.matchPaused || !this.isFinalCombatActive()) {
|
||||
if (
|
||||
!FINAL_COMBAT_SLOW_MOTION_ENABLED
|
||||
|| 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.slowMotionRestoreState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
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.clearFinalCombatEffects();
|
||||
}, FINAL_COMBAT_SLOW_MOTION_DURATION);
|
||||
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) {
|
||||
@@ -528,7 +601,8 @@ update(time) {
|
||||
}
|
||||
|
||||
if (this.physics?.world) {
|
||||
this.physics.world.timeScale = scale;
|
||||
// Arcade Physics uses larger timeScale values for slower world steps.
|
||||
this.physics.world.timeScale = arcadePhysicsTimeScale(scale);
|
||||
}
|
||||
|
||||
if (this.tweens) {
|
||||
@@ -541,30 +615,45 @@ update(time) {
|
||||
}
|
||||
|
||||
clearFinalCombatEffects() {
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
this.slowMotionTimer = null;
|
||||
}
|
||||
this.clearSlowMotionTimer();
|
||||
this.cancelSlowMotionTransition();
|
||||
|
||||
if (this.slowMotionRestoreState) {
|
||||
const restore = this.slowMotionRestoreState;
|
||||
this.slowMotionRestoreState = null;
|
||||
this.restoreSceneTimeScale(restore);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.time) {
|
||||
this.time.timeScale = restore.clock;
|
||||
}
|
||||
clearSlowMotionTimer() {
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
this.slowMotionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.physics?.world) {
|
||||
this.physics.world.timeScale = restore.physics;
|
||||
}
|
||||
cancelSlowMotionTransition() {
|
||||
if (this.slowMotionTransitionFrame !== null) {
|
||||
globalThis.cancelAnimationFrame(this.slowMotionTransitionFrame);
|
||||
this.slowMotionTransitionFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.tweens) {
|
||||
this.tweens.timeScale = restore.tweens;
|
||||
}
|
||||
restoreSceneTimeScale(restore) {
|
||||
if (this.time) {
|
||||
this.time.timeScale = restore.clock;
|
||||
}
|
||||
|
||||
if (this.anims) {
|
||||
this.anims.globalTimeScale = restore.animations;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,19 +834,45 @@ update(time) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameWidth = Math.min(camera.displayWidth, ARENA_SIZE);
|
||||
const frameHeight = Math.min(camera.displayHeight, ARENA_SIZE);
|
||||
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(cameraMidX - frameWidth / 2, 0, ARENA_SIZE - frameWidth);
|
||||
const frameY = Phaser.Math.Clamp(cameraMidY - frameHeight / 2, 0, ARENA_SIZE - frameHeight);
|
||||
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.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_OUTLINE, 0x080a05, 0.95);
|
||||
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
|
||||
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_STROKE, 0xffe4a8, 1);
|
||||
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, 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) {
|
||||
@@ -912,6 +1027,151 @@ const DEATH_NOTICE_TEMPLATES = [
|
||||
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
|
||||
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||
];
|
||||
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
||||
const VICTORY_CONFETTI_COUNT = 40;
|
||||
const VICTORY_FANFARE_NOTES = [
|
||||
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
||||
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
||||
{ duration: 0.18, frequency: 493.88, offset: 0.13, volume: 0.064 },
|
||||
{ duration: 0.18, frequency: 659.25, offset: 0.13, volume: 0.05 },
|
||||
{ duration: 0.2, frequency: 587.33, offset: 0.28, volume: 0.062 },
|
||||
{ duration: 0.2, frequency: 783.99, offset: 0.28, volume: 0.048 },
|
||||
{ duration: 0.5, frequency: 523.25, offset: 0.46, volume: 0.064 },
|
||||
{ duration: 0.5, frequency: 659.25, offset: 0.46, volume: 0.052 },
|
||||
{ duration: 0.5, frequency: 783.99, offset: 0.46, volume: 0.043 },
|
||||
];
|
||||
|
||||
let victoryAudioContext = null;
|
||||
|
||||
function removeVictoryCelebration() {
|
||||
document.querySelector(".victory-celebration")?.remove();
|
||||
}
|
||||
|
||||
function createVictoryCelebration(message) {
|
||||
const celebrationHost = document.querySelector("#app") ?? document.querySelector(".arena-shell");
|
||||
|
||||
if (!celebrationHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVictory = message.includes("승리");
|
||||
const celebration = document.createElement("div");
|
||||
celebration.className = `victory-celebration ${isVictory ? "is-victory" : "is-draw"}`;
|
||||
celebration.setAttribute("aria-hidden", "true");
|
||||
|
||||
const rays = document.createElement("span");
|
||||
rays.className = "victory-rays";
|
||||
|
||||
const confetti = document.createElement("span");
|
||||
confetti.className = "victory-confetti";
|
||||
|
||||
if (isVictory) {
|
||||
Array.from({ length: VICTORY_CONFETTI_COUNT }, (_, index) => {
|
||||
confetti.appendChild(createVictoryConfettiPiece(index));
|
||||
});
|
||||
}
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "victory-banner";
|
||||
|
||||
const messageNode = document.createElement("span");
|
||||
messageNode.className = "victory-banner-message";
|
||||
messageNode.textContent = message;
|
||||
|
||||
banner.appendChild(messageNode);
|
||||
celebration.append(rays, confetti, banner);
|
||||
celebrationHost.appendChild(celebration);
|
||||
|
||||
if (isVictory) {
|
||||
playVictoryFanfare();
|
||||
}
|
||||
}
|
||||
|
||||
function createVictoryConfettiPiece(index) {
|
||||
const piece = document.createElement("i");
|
||||
const angle = (Math.PI * 2 * index) / VICTORY_CONFETTI_COUNT + (index % 4) * 0.11;
|
||||
const distance = 170 + (index % 8) * 26;
|
||||
const x = Math.round(Math.cos(angle) * distance);
|
||||
const y = Math.round(Math.sin(angle) * distance * 0.78);
|
||||
|
||||
piece.className = "victory-confetti-piece";
|
||||
piece.style.setProperty("--confetti-color", VICTORY_CONFETTI_COLORS[index % VICTORY_CONFETTI_COLORS.length]);
|
||||
piece.style.setProperty("--confetti-delay", `${(index % 10) * 18}ms`);
|
||||
piece.style.setProperty("--confetti-duration", `${880 + (index % 6) * 90}ms`);
|
||||
piece.style.setProperty("--confetti-spin", `${180 + (index % 9) * 58}deg`);
|
||||
piece.style.setProperty("--confetti-x", `${x}px`);
|
||||
piece.style.setProperty("--confetti-y", `${y}px`);
|
||||
piece.style.setProperty("--confetti-tilt", `${(index % 7) * 19 - 54}deg`);
|
||||
|
||||
return piece;
|
||||
}
|
||||
|
||||
function primeVictoryFanfareAudio() {
|
||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||
|
||||
if (!AudioContextClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!victoryAudioContext) {
|
||||
victoryAudioContext = new AudioContextClass();
|
||||
}
|
||||
|
||||
if (victoryAudioContext.state === "suspended") {
|
||||
victoryAudioContext.resume().catch(() => {});
|
||||
}
|
||||
|
||||
return victoryAudioContext;
|
||||
}
|
||||
|
||||
function playVictoryFanfare() {
|
||||
const audioContext = primeVictoryFanfareAudio();
|
||||
|
||||
if (!audioContext || audioContext.state !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const startAt = audioContext.currentTime + 0.03;
|
||||
|
||||
VICTORY_FANFARE_NOTES.forEach((note) => {
|
||||
playVictoryFanfareNote(audioContext, startAt + note.offset, note);
|
||||
});
|
||||
}
|
||||
|
||||
function playVictoryFanfareNote(audioContext, startAt, { duration, frequency, volume }) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
const releaseAt = startAt + duration;
|
||||
|
||||
oscillator.type = "triangle";
|
||||
oscillator.frequency.setValueAtTime(frequency, startAt);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.01, releaseAt);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.025);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
oscillator.start(startAt);
|
||||
oscillator.stop(releaseAt + 0.02);
|
||||
}
|
||||
|
||||
function easeOutCubic(progress) {
|
||||
return 1 - (1 - progress) ** 3;
|
||||
}
|
||||
|
||||
function easeInOutCubic(progress) {
|
||||
if (progress < 0.5) {
|
||||
return 4 * progress ** 3;
|
||||
}
|
||||
|
||||
return 1 - ((-2 * progress + 2) ** 3) / 2;
|
||||
}
|
||||
|
||||
function arcadePhysicsTimeScale(sceneTimeScale) {
|
||||
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
|
||||
}
|
||||
|
||||
function createDeathCounts() {
|
||||
return SPECIES_KEYS.reduce((counts, species) => {
|
||||
@@ -1000,10 +1260,12 @@ function fighterSkinIdleUrl(skin) {
|
||||
return `${skin.assetRoot}/${idleFile}`;
|
||||
}
|
||||
|
||||
function createFighterPlans(fighterSetups, skins) {
|
||||
function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
||||
return fighterSetups.flatMap((fighterSetup, index) => {
|
||||
const skin = skins[index];
|
||||
const spawnMultiplier = Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1));
|
||||
const spawnMultiplier = expandSpawnMultipliers
|
||||
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
||||
: 1;
|
||||
|
||||
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
|
||||
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
|
||||
|
||||
+53
-17
@@ -87,10 +87,10 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||
|
||||
switch (getCombatType(attacker)) {
|
||||
case "projectile":
|
||||
queueProjectile(scene, attacker, defender, onWinner);
|
||||
queueProjectile(scene, attacker, defender, onWinner, attack);
|
||||
return;
|
||||
case "instant-spell":
|
||||
queueInstantSpell(scene, attacker, defender, onWinner);
|
||||
queueInstantSpell(scene, attacker, defender, onWinner, attack);
|
||||
return;
|
||||
default:
|
||||
queueMeleeHit(scene, attacker, defender, onWinner, attack);
|
||||
@@ -102,12 +102,12 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
instantKill: attack.isCritical,
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function queueProjectile(scene, attacker, defender, onWinner) {
|
||||
function queueProjectile(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
|
||||
@@ -115,11 +115,11 @@ function queueProjectile(scene, attacker, defender, onWinner) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnProjectile(scene, attacker, defender, onWinner, matchId);
|
||||
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
|
||||
});
|
||||
}
|
||||
|
||||
function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||
function queueInstantSpell(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
|
||||
@@ -127,11 +127,11 @@ function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnSpellEffect(scene, attacker, defender, onWinner, matchId);
|
||||
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
const defenderHitPoint = fighterHitPoint(defender);
|
||||
const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint);
|
||||
const projectile = scene.physics.add.image(
|
||||
@@ -178,7 +178,9 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
|
||||
projectile.hasHit = true;
|
||||
disposeCombatObject(scene, projectile);
|
||||
applyHit(scene, attacker, defender, onWinner, matchId);
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
};
|
||||
|
||||
const overlap = scene.physics.add.overlap(projectile, defender, hitDefender);
|
||||
@@ -213,7 +215,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
});
|
||||
}
|
||||
|
||||
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
|
||||
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
|
||||
effect.setDepth(3);
|
||||
effect.setScale(FIGHTER_SCALE);
|
||||
@@ -227,17 +229,24 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
|
||||
() => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId);
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
|
||||
function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = false } = {}) {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defender.hp = instantKill
|
||||
if (isCritical) {
|
||||
spawnCriticalHitLabel(scene, defender);
|
||||
scene.cameras.main.shake(90, 0.002);
|
||||
}
|
||||
|
||||
defender.hp = isCritical
|
||||
? 0
|
||||
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
|
||||
defender.body.setVelocity(0, 0);
|
||||
@@ -249,10 +258,37 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
|
||||
|
||||
defender.isLocked = true;
|
||||
playAnimation(defender, "hurt");
|
||||
|
||||
if (instantKill) {
|
||||
scene.cameras.main.shake(90, 0.002);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnCriticalHitLabel(scene, defender) {
|
||||
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER_SCALE);
|
||||
const label = scene.add
|
||||
.text(defender.x, defender.y - 44 * scaleRatio - 24, "Critical!", {
|
||||
color: "#ffe45c",
|
||||
fontFamily: "Inter, Pretendard, sans-serif",
|
||||
fontSize: "24px",
|
||||
fontStyle: "900",
|
||||
stroke: "#7b1b11",
|
||||
strokeThickness: 5,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(6);
|
||||
|
||||
label.cleanup = () => {
|
||||
scene.tweens.killTweensOf(label);
|
||||
};
|
||||
|
||||
trackCombatObject(scene, label);
|
||||
scene.tweens.add({
|
||||
targets: label,
|
||||
y: label.y - 32,
|
||||
alpha: 0,
|
||||
scaleX: 1.12,
|
||||
scaleY: 1.12,
|
||||
duration: 520,
|
||||
ease: "Cubic.Out",
|
||||
onComplete: () => disposeCombatObject(scene, label),
|
||||
});
|
||||
}
|
||||
|
||||
function getAttackRange(fighter) {
|
||||
|
||||
+67
-5
@@ -1,13 +1,19 @@
|
||||
import {
|
||||
ARENA_SIZE,
|
||||
DEFAULT_SPAWN_PLACEMENT,
|
||||
DEFAULT_TEAM_SIZE,
|
||||
GRID_SIZE,
|
||||
getTeamColor,
|
||||
MAX_TEAM_SIZE,
|
||||
SPAWN_PLACEMENTS,
|
||||
TILE_SIZE,
|
||||
} from "../constants.js";
|
||||
|
||||
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
export function createMatchSetup(
|
||||
names,
|
||||
requestedTeamSize = DEFAULT_TEAM_SIZE,
|
||||
requestedSpawnPlacement = DEFAULT_SPAWN_PLACEMENT,
|
||||
) {
|
||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
||||
const teams = names.map((name, index) => ({
|
||||
color: getTeamColor(index, names.length),
|
||||
@@ -16,8 +22,7 @@ export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
size: teamSize,
|
||||
}));
|
||||
|
||||
const totalFighters = names.length * teamSize;
|
||||
const spawns = createRandomSpawnPoints(totalFighters);
|
||||
const spawns = createSpawnPoints(names.length, teamSize, requestedSpawnPlacement);
|
||||
|
||||
const fighters = [];
|
||||
names.forEach((name, teamIndex) => {
|
||||
@@ -58,11 +63,56 @@ function createTeams(playerCount, teamSize) {
|
||||
}));
|
||||
}
|
||||
|
||||
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement) {
|
||||
if (requestedSpawnPlacement === SPAWN_PLACEMENTS.STARTING_ZONES) {
|
||||
return createStartingZoneSpawnPoints(teamCount, teamSize);
|
||||
}
|
||||
|
||||
return createRandomSpawnPoints(teamCount * teamSize);
|
||||
}
|
||||
|
||||
function createRandomSpawnPoints(count) {
|
||||
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
||||
}
|
||||
|
||||
function createStartingZoneSpawnPoints(teamCount, teamSize) {
|
||||
const fallbackSlots = createSpawnSlots();
|
||||
const layout = shuffle(createStartingZoneLayout(teamCount));
|
||||
|
||||
return layout.flatMap((zone) => {
|
||||
const zoneSlots = createSpawnSlots(zone);
|
||||
return createSpawnPointsFromSlots(zoneSlots.length > 0 ? zoneSlots : fallbackSlots, teamSize);
|
||||
});
|
||||
}
|
||||
|
||||
function createStartingZoneLayout(teamCount) {
|
||||
const columnCount = Math.max(1, Math.ceil(Math.sqrt(teamCount)));
|
||||
const rowCount = Math.max(1, Math.ceil(teamCount / columnCount));
|
||||
const availableRows = GRID_SIZE - 2;
|
||||
|
||||
return Array.from({ length: teamCount }, (_, index) => {
|
||||
const column = index % columnCount;
|
||||
const row = Math.floor(index / columnCount);
|
||||
|
||||
return {
|
||||
columnEnd: partitionEnd(GRID_SIZE, columnCount, column),
|
||||
columnStart: partitionStart(GRID_SIZE, columnCount, column),
|
||||
rowEnd: 1 + partitionEnd(availableRows, rowCount, row),
|
||||
rowStart: 1 + partitionStart(availableRows, rowCount, row),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createSpawnSlots({
|
||||
columnEnd = GRID_SIZE,
|
||||
columnStart = 0,
|
||||
rowEnd = GRID_SIZE - 1,
|
||||
rowStart = 1,
|
||||
} = {}) {
|
||||
const spawnSlots = [];
|
||||
|
||||
for (let row = 1; row < GRID_SIZE - 1; row += 1) {
|
||||
for (let column = 0; column < GRID_SIZE; column += 1) {
|
||||
for (let row = rowStart; row < rowEnd; row += 1) {
|
||||
for (let column = columnStart; column < columnEnd; column += 1) {
|
||||
spawnSlots.push({
|
||||
x: column * TILE_SIZE + TILE_SIZE / 2,
|
||||
y: row * TILE_SIZE + TILE_SIZE / 2,
|
||||
@@ -70,6 +120,10 @@ function createRandomSpawnPoints(count) {
|
||||
}
|
||||
}
|
||||
|
||||
return spawnSlots;
|
||||
}
|
||||
|
||||
function createSpawnPointsFromSlots(spawnSlots, count) {
|
||||
const points = [];
|
||||
|
||||
while (points.length < count) {
|
||||
@@ -89,6 +143,14 @@ function createRandomSpawnPoints(count) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function partitionStart(size, partCount, partIndex) {
|
||||
return Math.floor((size * partIndex) / partCount);
|
||||
}
|
||||
|
||||
function partitionEnd(size, partCount, partIndex) {
|
||||
return partitionStart(size, partCount, partIndex + 1);
|
||||
}
|
||||
|
||||
function resolveTeamSize(playerCount, requestedTeamSize) {
|
||||
const teamSize = clamp(
|
||||
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
|
||||
|
||||
+13
-2
@@ -1,6 +1,10 @@
|
||||
import Phaser from "phaser";
|
||||
import { ArenaScene } from "./game/ArenaScene.js";
|
||||
import { ARENA_SIZE } from "./constants.js";
|
||||
import {
|
||||
ARENA_SIZE,
|
||||
PRESENTATION_TEAM_COUNT,
|
||||
PRESENTATION_TEAM_SIZE,
|
||||
} from "./constants.js";
|
||||
import { createMatchForm } from "./ui/matchForm.js";
|
||||
import { trackVisitor } from "./ui/visitorCounter.js";
|
||||
|
||||
@@ -55,6 +59,13 @@ function startConfiguredMatch(matchConfig) {
|
||||
syncPauseButton();
|
||||
}
|
||||
|
||||
function getPresentationMatchConfig() {
|
||||
return {
|
||||
names: Array.from({ length: PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
|
||||
teamSize: PRESENTATION_TEAM_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function setDrawerCollapsed(collapsed) {
|
||||
const nextCollapsed = Boolean(collapsed) && isMatchLive();
|
||||
|
||||
@@ -121,7 +132,7 @@ window.addEventListener("keydown", (event) => {
|
||||
});
|
||||
|
||||
const arenaScene = new ArenaScene({
|
||||
getInitialMatchConfig: matchForm.readMatchConfig,
|
||||
getInitialMatchConfig: getPresentationMatchConfig,
|
||||
setStatus: matchForm.setStatus,
|
||||
});
|
||||
|
||||
|
||||
+319
-24
@@ -256,7 +256,6 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
#app.options-open:not(.match-live) .intro-content {
|
||||
transform: translateX(-14vw) scale(0.92);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@@ -327,6 +326,11 @@ form button[type="submit"],
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#app.options-open:not(.match-live) .start-button {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.start-button:hover,
|
||||
form button[type="submit"]:hover,
|
||||
.pause-button:hover,
|
||||
@@ -582,13 +586,10 @@ legend {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
output {
|
||||
.team-size-number {
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
border: 1px solid rgb(238 185 73 / 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #1d2116;
|
||||
color: #fff7df;
|
||||
padding-inline: 10px;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -598,7 +599,7 @@ label {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
input:not([type="range"]),
|
||||
input:not([type="range"]):not([type="radio"]),
|
||||
textarea {
|
||||
min-height: 48px;
|
||||
border: 1px solid rgb(238 185 73 / 0.28);
|
||||
@@ -621,6 +622,66 @@ input[type="range"] {
|
||||
accent-color: #e3b24f;
|
||||
}
|
||||
|
||||
.spawn-placement-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spawn-placement-label {
|
||||
color: #ead8ad;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.spawn-placement-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgb(238 185 73 / 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
background: #1d2116;
|
||||
}
|
||||
|
||||
.spawn-placement-option {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spawn-placement-option input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spawn-placement-option span {
|
||||
display: grid;
|
||||
min-height: 44px;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
color: #ead8ad;
|
||||
text-align: center;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spawn-placement-option input:checked + span {
|
||||
border-color: rgb(238 185 73 / 0.36);
|
||||
background: #323822;
|
||||
color: #fff7df;
|
||||
}
|
||||
|
||||
.spawn-placement-option input:focus-visible + span {
|
||||
outline: 2px solid #f1c761;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.scoreboard {
|
||||
position: fixed;
|
||||
top: clamp(14px, 3vw, 28px);
|
||||
@@ -843,6 +904,7 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.kill-log-avatar {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -856,6 +918,31 @@ input[type="range"] {
|
||||
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim .kill-log-avatar::before,
|
||||
.kill-log-fighter.victim .kill-log-avatar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 1px;
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border: 1px solid rgb(255 216 212 / 0.22);
|
||||
border-radius: 999px;
|
||||
background: #f24a42;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(48 4 3 / 0.7),
|
||||
0 0 5px rgb(227 54 46 / 0.6);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim .kill-log-avatar::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim .kill-log-avatar::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.kill-log-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -932,26 +1019,149 @@ input[type="range"] {
|
||||
transform: translate(-50%, -50%) rotate(-42deg);
|
||||
}
|
||||
|
||||
.victory-banner {
|
||||
.victory-celebration {
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
inset: 0;
|
||||
background: rgb(4 6 4 / 0.2);
|
||||
isolation: isolate;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.victory-celebration::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: min(122vmin, 1240px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle, rgb(255 233 166 / 0.18) 0 18%, rgb(227 178 79 / 0.12) 31%, transparent 66%);
|
||||
animation: victory-glow 1.8s ease-out both;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw::before {
|
||||
background:
|
||||
radial-gradient(circle, rgb(255 247 223 / 0.16) 0 18%, rgb(227 178 79 / 0.1) 31%, transparent 62%);
|
||||
}
|
||||
|
||||
.victory-rays {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: min(112vmin, 1120px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: repeating-conic-gradient(
|
||||
from -4deg,
|
||||
rgb(255 233 166 / 0.18) 0 8deg,
|
||||
transparent 8deg 18deg
|
||||
);
|
||||
opacity: 0.54;
|
||||
mask-image: radial-gradient(circle, #000 0 18%, transparent 66%);
|
||||
animation: victory-rays-in 1.1s ease-out both, victory-rays-turn 11s linear infinite;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw .victory-rays {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.victory-confetti {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.victory-confetti-piece {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 9;
|
||||
max-width: min(92vw, 720px);
|
||||
border: 2px solid #e3b24f;
|
||||
display: block;
|
||||
width: clamp(6px, 0.8vw, 11px);
|
||||
height: clamp(10px, 1.2vw, 18px);
|
||||
border-radius: 8px;
|
||||
padding: 1.3rem 2.4rem;
|
||||
background: rgb(4 6 4 / 0.88);
|
||||
background: var(--confetti-color);
|
||||
box-shadow: 0 0 12px rgb(255 230 166 / 0.22);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
|
||||
animation: victory-confetti-burst var(--confetti-duration) cubic-bezier(0.15, 0.84, 0.35, 1) var(--confetti-delay) both;
|
||||
}
|
||||
|
||||
.victory-confetti-piece:nth-child(3n) {
|
||||
width: clamp(10px, 1vw, 15px);
|
||||
height: clamp(6px, 0.72vw, 10px);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.victory-banner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: min(calc(100vw - 36px), 760px);
|
||||
min-height: clamp(108px, 18vw, 170px);
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border: 2px solid #f1c45d;
|
||||
border-radius: 8px;
|
||||
padding: clamp(1.25rem, 3.8vw, 2rem) clamp(1.3rem, 5.4vw, 3.4rem);
|
||||
background:
|
||||
linear-gradient(135deg, rgb(18 21 13 / 0.98), rgb(3 5 4 / 0.92)),
|
||||
rgb(4 6 4 / 0.9);
|
||||
color: #fff7df;
|
||||
font-size: clamp(1.45rem, 5vw, 2.4rem);
|
||||
font-size: clamp(1.65rem, 5vw, 3rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.12;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 34px rgb(227 178 79 / 0.34);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: banner-in 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
text-wrap: balance;
|
||||
text-shadow:
|
||||
0 2px 0 rgb(55 36 8 / 0.56),
|
||||
0 0 24px rgb(255 226 153 / 0.28);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 237 187 / 0.2) inset,
|
||||
0 0 42px rgb(227 178 79 / 0.44),
|
||||
0 24px 90px rgb(0 0 0 / 0.58);
|
||||
animation: banner-in 0.64s cubic-bezier(0.16, 0.9, 0.25, 1.2);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.victory-banner::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -40% auto -40% -36%;
|
||||
width: 28%;
|
||||
background: linear-gradient(90deg, transparent, rgb(255 248 223 / 0.6), transparent);
|
||||
transform: skewX(-18deg);
|
||||
animation: victory-banner-sheen 1s 0.28s ease-out both;
|
||||
}
|
||||
|
||||
.victory-banner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgb(255 225 151 / 0.24);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.victory-banner-message {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
animation: victory-message-pulse 1.1s 0.2s ease-out both;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw .victory-banner {
|
||||
border-color: #d8c28d;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 237 187 / 0.14) inset,
|
||||
0 0 28px rgb(227 178 79 / 0.24),
|
||||
0 24px 90px rgb(0 0 0 / 0.52);
|
||||
}
|
||||
|
||||
#app.match-paused .arena-shell::after {
|
||||
content: "일시정지";
|
||||
position: fixed;
|
||||
@@ -1091,11 +1301,100 @@ input[type="range"] {
|
||||
@keyframes banner-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -56%) scale(0.86);
|
||||
transform: translateY(18px) scale(0.78);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-banner-sheen {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0) skewX(-18deg);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(560%) skewX(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-confetti-burst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
74% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform:
|
||||
translate(calc(-50% + var(--confetti-x)), calc(-50% + var(--confetti-y)))
|
||||
rotate(calc(var(--confetti-tilt) + var(--confetti-spin)))
|
||||
scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-glow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.58);
|
||||
}
|
||||
35% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-rays-in {
|
||||
from {
|
||||
transform: scale(0.56);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-rays-turn {
|
||||
to {
|
||||
rotate: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-message-pulse {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
58% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.victory-banner,
|
||||
.victory-banner::before,
|
||||
.victory-banner-message,
|
||||
.victory-celebration::before,
|
||||
.victory-confetti-piece,
|
||||
.victory-rays {
|
||||
animation-duration: 1ms;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1126,10 +1425,6 @@ input[type="range"] {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#app.options-open:not(.match-live) .intro-content {
|
||||
transform: translateY(-16vh) scale(0.86);
|
||||
}
|
||||
|
||||
.arena-logo {
|
||||
font-size: clamp(3.8rem, 22vw, 7rem);
|
||||
}
|
||||
|
||||
+79
-14
@@ -1,7 +1,8 @@
|
||||
import { NICKNAME_LENGTH } from "../constants.js";
|
||||
import { DEFAULT_SPAWN_PLACEMENT, NICKNAME_LENGTH } from "../constants.js";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
names: "arena.match.playerNames",
|
||||
spawnPlacement: "arena.match.spawnPlacement",
|
||||
teamSize: "arena.match.teamSize",
|
||||
};
|
||||
|
||||
@@ -11,22 +12,42 @@ export function createMatchForm() {
|
||||
const appNode = document.querySelector("#app");
|
||||
const statusNode = document.querySelector("#match-status");
|
||||
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
||||
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
||||
const teamSizeInput = getElement("#team-size");
|
||||
const teamSizeOutput = getElement("#team-size-value");
|
||||
const teamSizeNumberInput = getElement("#team-size-value");
|
||||
|
||||
const readMatchConfig = () => ({
|
||||
names: nicknameValues(namesInput.value),
|
||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||
teamSize: Number(teamSizeInput.value),
|
||||
});
|
||||
|
||||
restoreSavedMatchSettings(namesInput, teamSizeInput);
|
||||
syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
|
||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
|
||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
||||
namesInput.addEventListener("input", () => {
|
||||
saveMatchSettings(namesInput, teamSizeInput);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
teamSizeInput.addEventListener("input", () => {
|
||||
syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
|
||||
saveMatchSettings(namesInput, teamSizeInput);
|
||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
teamSizeNumberInput.addEventListener("input", () => {
|
||||
if (syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput, teamSizeNumberInput.value)) {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
}
|
||||
});
|
||||
teamSizeNumberInput.addEventListener("change", () => {
|
||||
syncTeamSizeInputs(
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
teamSizeNumberInput.value || teamSizeInput.value,
|
||||
);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
spawnPlacementInputs.forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -62,6 +83,16 @@ function getElement(selector) {
|
||||
return element;
|
||||
}
|
||||
|
||||
function getElements(selector) {
|
||||
const elements = [...document.querySelectorAll(selector)];
|
||||
|
||||
if (elements.length === 0) {
|
||||
throw new Error(`Missing required elements: ${selector}`);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function nicknameValues(value) {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
@@ -69,11 +100,25 @@ function nicknameValues(value) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function syncTeamSizeOutput(input, output) {
|
||||
output.textContent = input.value;
|
||||
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
||||
|
||||
if (!normalizedTeamSize) {
|
||||
return "";
|
||||
}
|
||||
|
||||
rangeInput.value = normalizedTeamSize;
|
||||
numberInput.value = normalizedTeamSize;
|
||||
|
||||
return normalizedTeamSize;
|
||||
}
|
||||
|
||||
function restoreSavedMatchSettings(namesInput, teamSizeInput) {
|
||||
function restoreSavedMatchSettings(
|
||||
namesInput,
|
||||
spawnPlacementInputs,
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
) {
|
||||
const storage = getLocalStorage();
|
||||
|
||||
if (!storage) {
|
||||
@@ -82,6 +127,7 @@ function restoreSavedMatchSettings(namesInput, teamSizeInput) {
|
||||
|
||||
try {
|
||||
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
||||
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
||||
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
|
||||
|
||||
if (savedNames !== null) {
|
||||
@@ -90,15 +136,19 @@ function restoreSavedMatchSettings(namesInput, teamSizeInput) {
|
||||
|
||||
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
|
||||
|
||||
if (normalizedTeamSize) {
|
||||
teamSizeInput.value = normalizedTeamSize;
|
||||
}
|
||||
syncTeamSizeInputs(
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
normalizedTeamSize || teamSizeInput.value,
|
||||
);
|
||||
|
||||
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
||||
} catch {
|
||||
// Storage may be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function saveMatchSettings(namesInput, teamSizeInput) {
|
||||
function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
||||
const storage = getLocalStorage();
|
||||
|
||||
if (!storage) {
|
||||
@@ -107,12 +157,27 @@ function saveMatchSettings(namesInput, teamSizeInput) {
|
||||
|
||||
try {
|
||||
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
||||
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
||||
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
|
||||
} catch {
|
||||
// Ignore storage failures so the match form remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
function selectedSpawnPlacement(inputs) {
|
||||
return inputs.find((input) => input.checked)?.value ?? DEFAULT_SPAWN_PLACEMENT;
|
||||
}
|
||||
|
||||
function setSpawnPlacement(inputs, value) {
|
||||
const savedInput = inputs.find((input) => input.value === value);
|
||||
const defaultInput = inputs.find((input) => input.value === DEFAULT_SPAWN_PLACEMENT);
|
||||
const nextInput = savedInput ?? defaultInput;
|
||||
|
||||
if (nextInput) {
|
||||
nextInput.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTeamSize(value, input) {
|
||||
const min = Number(input.min) || 1;
|
||||
const max = Number(input.max) || min;
|
||||
|
||||
Reference in New Issue
Block a user