Add critical hits, victory celebrations, and enhanced match settings

This commit is contained in:
2026-05-23 01:20:04 +09:00
parent a03dc02d01
commit 25137cf26e
12 changed files with 1202 additions and 152 deletions
+21 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;