feat: implement dense-area meteor barrage, large-battle optimization, and direct fighter count input

This commit is contained in:
2026-05-27 01:20:31 +09:00
parent 2c013247a9
commit 30d7be41be
16 changed files with 553 additions and 319 deletions
+20 -13
View File
@@ -74,8 +74,8 @@ export const FIGHTER = {
};
export const PERFORMANCE = {
LARGE_BATTLE_FIGHTER_THRESHOLD: 500,
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 700,
LARGE_BATTLE_FIGHTER_THRESHOLD: 3000,
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
FIGHTER_HUD_POOL_SIZE: 96,
FIGHTER_HUD_VISIBLE_LIMIT: 72,
@@ -88,19 +88,20 @@ export const PERFORMANCE = {
// 3. SPAWN 도메인
export const SPAWN = {
DEFAULT_TEAM_SIZE: 5,
DEFAULT_PLACEMENT: "random",
PLACEMENTS: {
RANDOM: "random",
STARTING_ZONES: "starting-zones",
},
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
MAX_FIGHTER_COUNT: 8000,
FIGHTERS_PER_STARTING_ZONE: 100,
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
PRESENTATION_TEAM_COUNT: 10,
PRESENTATION_TEAM_SIZE: 5,
MAX_TEAM_SIZE: 50,
};
// 4. COMBAT 도메인
@@ -130,19 +131,25 @@ export const PROJECTILE = {
// 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = {
INTERVAL: 4000,
AREA_TILES: 15,
SIZE_SCALE_VARIANCE: 3,
// Delay from match start until the first barrage.
INTERVAL: 8000,
// Delay between barrages after the first one has fired.
REPEAT_INTERVAL: 8000,
AREA_TILES: 40,
// How long the large dense-area warning marker remains visible.
WARNING_DURATION_MS: 2000,
IMPACT_AREA_TILES: 10,
IMPACT_COUNT_MIN: 15,
IMPACT_COUNT_MAX: 25,
IMPACT_STAGGER_MS: 140,
IMPACT_VISUAL_SCALE: 10,
SIZE_SCALE_VARIANCE: 0,
FRAMES: 7,
FRAME_RATE: 14,
FALL_DURATION: 920,
FALL_TRAVEL_TILES: 8,
VISUAL_SCALE: 50,
METEOR_SHAKE_DURATION_MS: 150,
METEOR_SHAKE_INTENSITY: 0.004,
// 0 keeps target selection proportional to living units.
// 1 adds pressure when a team's living share exceeds its paid spawn share.
DOMINANCE_TARGETING_MULTIPLIER: 0.5,
METEOR_DAMAGE: 90,
FROST_DAMAGE: 45,
FROST_STUN_DURATION: 2000,
@@ -163,7 +170,7 @@ export const CAMERA = {
MAX_ZOOM: 3,
ZOOM_STEP: 0.1,
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
METEOR_FOCUS_ENABLED: true,
METEOR_FOCUS_ENABLED: false,
METEOR_FOCUS_ZOOM: 2,
SPECTATOR_LERP: 0.1,
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
@@ -173,7 +180,7 @@ export const CAMERA = {
SPECTATOR_FINAL_TEAM_COUNT: 2,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
SPECTATOR_LATE_FIGHTER_THRESHOLD: 30,
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
SPECTATOR_LATE_FIGHT_ZOOM: 2,
SELECTED_FIGHTER_ZOOM: 2,
};
+38 -10
View File
@@ -24,7 +24,11 @@ import {
} from "../fighter/fighterFactory.js";
import { fighterManifest } from "../fighter/fighterManifest.js";
import { pickFighters } from "../fighter/fighterSelection.js";
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
import {
createMatchSetup,
FighterCountLimitError,
matchStatusText,
} from "../match/matchSetup.js";
import { trackMatchFinish, trackMatchStart } from "../../ui/dailyMetrics.js";
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
@@ -61,7 +65,7 @@ import {
} from "../../ui/battleDeathNotice.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, onMatchEnd, setStatus }) {
constructor({ getInitialMatchConfig, onMatchEnd, setPlayerNamesWarning, setStatus }) {
super("arena");
this.fighters = [];
this.getInitialMatchConfig = getInitialMatchConfig;
@@ -71,6 +75,8 @@ export class ArenaScene extends Phaser.Scene {
this.onMatchEnd = typeof onMatchEnd === "function" ? onMatchEnd : () => {};
this.presentationMode = true;
this.ready = false;
this.setPlayerNamesWarning =
typeof setPlayerNamesWarning === "function" ? setPlayerNamesWarning : () => {};
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
this.setStatus = (message) => {
this.updateStatus(message);
@@ -165,25 +171,46 @@ export class ArenaScene extends Phaser.Scene {
this.startMatch(this.getInitialMatchConfig(), { silent: true });
}
startMatch({ names = [], spawnPlacement, teamSize } = {}, { silent = false } = {}) {
startMatch({ names = [], spawnPlacement } = {}, { silent = false } = {}) {
if (!this.ready) {
return;
return false;
}
if (!silent) {
this.setPlayerNamesWarning();
}
if (names.length < 2) {
this.setStatus("참가자 닉네임을 2명 이상 입력하세요.");
return;
return false;
}
let matchSetup;
try {
matchSetup = createMatchSetup(names, spawnPlacement);
} catch (error) {
if (error instanceof FighterCountLimitError) {
this.setPlayerNamesWarning({
title: "최대 출전 인원 초과",
fighterCount: error.fighterCount,
maxFighterCount: error.maxFighterCount,
});
return false;
}
throw error;
}
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
expandSpawnMultipliers: !silent,
});
if (!silent) {
primeVictoryFanfareAudio();
}
const matchSetup = createMatchSetup(names, teamSize, spawnPlacement);
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
expandSpawnMultipliers: !silent,
});
syncTeamSizes(matchSetup.teams, fighterPlans);
this.matchId += 1;
@@ -215,6 +242,7 @@ export class ArenaScene extends Phaser.Scene {
}
this.updateScoreboard();
return true;
}
showStartingZones(startingZones) {
+27 -10
View File
@@ -223,15 +223,17 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
}
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);
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
if (shouldRenderCombatEffects(scene)) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3);
effect.setScale(FIGHTER.SCALE);
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
disposeCombatObject(scene, effect);
});
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
disposeCombatObject(scene, effect);
});
}
scene.time.delayedCall(
scaledAttackDelay(combatStatsFor(attacker).effectHitDelay, attacker),
@@ -248,7 +250,7 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
return;
}
if (isCritical) {
if (isCritical && shouldRenderCombatEffects(scene)) {
spawnCriticalHitLabel(scene, defender);
}
@@ -526,11 +528,18 @@ function applyKillReward(winner) {
const nextScaleX = (winner.baseScaleX ?? FIGHTER.SCALE) * rewardMultiplier;
const nextScaleY = (winner.baseScaleY ?? FIGHTER.SCALE) * rewardMultiplier;
const renderEffects = shouldRenderCombatEffects(winner.scene);
if (nextHp > previousHp) {
if (nextHp > previousHp && renderEffects) {
spawnKillHealEffect(winner, Math.max(Math.abs(nextScaleX), Math.abs(nextScaleY)));
}
if (!renderEffects) {
winner.setScale(nextScaleX, nextScaleY);
clampFighterInsideArena(winner);
return;
}
winner.scene.tweens.add({
targets: winner,
scaleX: nextScaleX,
@@ -609,6 +618,14 @@ function resolveDeadDespawnDelay(scene) {
return Math.max(0, Number(delay) || 0);
}
function shouldRenderCombatEffects(scene) {
const fighterCount = scene.fighters?.length ?? 0;
const isLargeBattle =
fighterCount >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD);
return !isLargeBattle || Boolean(scene.meteorFocusState);
}
function createTargetSpatialIndex(fighters) {
const cellSize = Math.max(1, Number(PERFORMANCE.TARGET_GRID_CELL_SIZE) || ARENA.TILE_SIZE);
const maxCellX = Math.floor((ARENA.SIZE - 1) / cellSize);
+221 -96
View File
@@ -60,12 +60,16 @@ export function startWorldEffects(scene) {
scene.matchStartedAt = scene.time.now;
scene.isSuddenDeath = false;
const scheduleNext = () => {
const scheduleNext = (isInitialBarrage = false) => {
if (!isLiveMatch(scene)) return;
const elapsed = scene.time.now - (scene.matchStartedAt ?? scene.time.now);
const isSuddenDeath = WORLD_EFFECT.SUDDEN_DEATH.ENABLED && elapsed >= WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS;
const delay = isSuddenDeath ? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS : WORLD_EFFECT.INTERVAL;
const delay = isInitialBarrage
? WORLD_EFFECT.INTERVAL
: isSuddenDeath
? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS
: WORLD_EFFECT.REPEAT_INTERVAL;
if (isSuddenDeath && !scene.isSuddenDeath) {
scene.isSuddenDeath = true;
@@ -77,7 +81,7 @@ export function startWorldEffects(scene) {
});
};
scheduleNext();
scheduleNext(true);
}
export function clearWorldEffects(scene) {
@@ -124,8 +128,7 @@ function triggerWorldEffect(scene) {
return;
}
const target = chooseWorldEffectTarget(livingFighters);
const zone = createEffectZone(target, resolveWorldEffectSizeScale());
const zone = findDensestWorldEffectZone(livingFighters);
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
@@ -136,69 +139,53 @@ function triggerWorldEffect(scene) {
spawnMeteor(scene, zone);
}
export function chooseWorldEffectTarget(livingFighters) {
export function findDensestWorldEffectZone(livingFighters) {
if (livingFighters.length === 0) {
return null;
}
const dominanceMultiplier = Math.max(
0,
Number(WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER) || 0,
const areaTiles = resolveTileCount(WORLD_EFFECT.AREA_TILES, ARENA.GRID_SIZE);
const tileCounts = Array.from(
{ length: ARENA.GRID_SIZE },
() => Array(ARENA.GRID_SIZE).fill(0),
);
if (dominanceMultiplier === 0) {
return randomEntry(livingFighters);
}
livingFighters.forEach((fighter) => {
const x = fighter.body?.center.x ?? fighter.x;
const y = fighter.body?.center.y ?? fighter.y;
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
const teamPools = Array.from(
livingFighters.reduce((pools, fighter) => {
const teamId = fighter.team.id;
const teamPool = pools.get(teamId) ?? {
fighters: [],
purchasedWeight: resolvePurchasedWeight(fighter.team),
};
teamPool.fighters.push(fighter);
pools.set(teamId, teamPool);
return pools;
}, new Map()).values(),
);
const totalLivingFighters = livingFighters.length;
const totalPurchasedWeight = teamPools.reduce(
(total, teamPool) => total + teamPool.purchasedWeight,
0,
);
teamPools.forEach((teamPool) => {
const livingShare = teamPool.fighters.length / totalLivingFighters;
const purchasedShare = teamPool.purchasedWeight / totalPurchasedWeight;
const dominanceRatio = livingShare / purchasedShare;
const excessDominance = Math.max(0, dominanceRatio - 1);
teamPool.targetWeight =
teamPool.fighters.length * (1 + excessDominance * dominanceMultiplier);
tileCounts[row][column] += 1;
});
return randomEntry(weightedEntry(teamPools).fighters);
}
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
const densitySums = createSummedAreaTable(tileCounts);
const maximumOrigin = ARENA.GRID_SIZE - areaTiles;
let highestCount = -1;
let densestOrigins = [];
function resolvePurchasedWeight(team) {
return Math.max(1, Number(team?.multiplier) || 1);
}
for (let row = 0; row <= maximumOrigin; row += 1) {
for (let column = 0; column <= maximumOrigin; column += 1) {
const count = sumArea(densitySums, column, row, areaTiles);
function weightedEntry(entries) {
const totalWeight = entries.reduce((total, entry) => total + entry.targetWeight, 0);
let randomWeight = Math.random() * totalWeight;
for (const entry of entries) {
randomWeight -= entry.targetWeight;
if (randomWeight <= 0) {
return entry;
if (count > highestCount) {
highestCount = count;
densestOrigins = [{ column, row }];
} else if (count === highestCount) {
densestOrigins.push({ column, row });
}
}
}
return entries[entries.length - 1];
const origin = randomEntry(densestOrigins);
const size = areaTiles * ARENA.TILE_SIZE;
return createEffectZone(
origin.column * ARENA.TILE_SIZE + size / 2,
origin.row * ARENA.TILE_SIZE + size / 2,
areaTiles,
);
}
function randomEntry(entries) {
@@ -206,49 +193,97 @@ function randomEntry(entries) {
}
function spawnMeteor(scene, zone) {
const marker = createZoneMarker(scene, zone, METEOR_ZONE_COLOR);
scene.beginMeteorCameraFocus?.(zone);
dropWorldEffectSprite(scene, zone, {
spawnWorldEffectBarrage(scene, zone, {
color: METEOR_ZONE_COLOR,
damage: WORLD_EFFECT.METEOR_DAMAGE,
effectKey: METEOR_EFFECT_KEY,
onCancel: () => {
scene.clearMeteorCameraFocus?.(zone);
disposeCombatObject(scene, marker);
},
onImpact: () => {
scene.tweens.killTweensOf(marker);
marker.setAlpha(1);
applyMeteorImpactShake(scene, zone);
resolveImpactDamage(scene, zone, WORLD_EFFECT.METEOR_DAMAGE);
},
onAnimationComplete: () => {
scene.clearMeteorCameraFocus?.(zone);
disposeCombatObject(scene, marker);
},
});
}
function spawnFrostZone(scene, zone) {
const marker = createZoneMarker(scene, zone, FROST_ZONE_COLOR);
scene.beginMeteorCameraFocus?.(zone);
dropWorldEffectSprite(scene, zone, {
spawnWorldEffectBarrage(scene, zone, {
color: FROST_ZONE_COLOR,
damage: WORLD_EFFECT.FROST_DAMAGE,
effectKey: FROST_EFFECT_KEY,
onCancel: () => {
scene.clearMeteorCameraFocus?.(zone);
disposeCombatObject(scene, marker);
},
onImpact: () => {
applyMeteorImpactShake(scene, zone);
resolveImpactDamage(scene, zone, WORLD_EFFECT.FROST_DAMAGE, (fighter) => {
applyFrostStun(scene, fighter);
});
isFrost: true,
});
}
if (!scene.matchOver) {
activateFrostZone(scene, zone, marker);
function spawnWorldEffectBarrage(
scene,
targetZone,
{ color, damage, effectKey, isFrost = false },
) {
const matchId = scene.matchId;
const targetMarker = createZoneMarker(scene, targetZone, color);
const impactZones = createBarrageImpactZones(targetZone);
const pendingTimers = [];
const warningHideTimer = scene.time.delayedCall(resolveWarningDurationMs(), () => {
hideZoneMarker(scene, targetMarker);
});
let unresolvedImpacts = impactZones.length;
scene.beginMeteorCameraFocus?.(targetZone);
const finishImpact = () => {
unresolvedImpacts -= 1;
if (unresolvedImpacts > 0) {
return;
}
scene.clearMeteorCameraFocus?.(targetZone);
disposeCombatObject(scene, targetMarker);
};
const previousCleanup = targetMarker.cleanup;
targetMarker.cleanup = () => {
warningHideTimer.remove(false);
pendingTimers.forEach((timer) => timer.remove(false));
previousCleanup?.();
};
impactZones.forEach((impactZone, index) => {
const timer = scene.time.delayedCall(resolveImpactStaggerMs() * index, () => {
if (!targetMarker.active || !isLiveMatch(scene, matchId)) {
finishImpact();
return;
}
},
onAnimationComplete: () => scene.clearMeteorCameraFocus?.(zone),
const impactMarker = createZoneMarker(scene, impactZone, color);
dropWorldEffectSprite(scene, impactZone, {
effectKey,
onCancel: () => {
disposeCombatObject(scene, impactMarker);
finishImpact();
},
onImpact: () => {
scene.tweens.killTweensOf(impactMarker);
impactMarker.setAlpha(1);
applyMeteorImpactShake(scene, impactZone);
resolveImpactDamage(
scene,
impactZone,
damage,
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
);
if (isFrost && !scene.matchOver) {
activateFrostZone(scene, impactZone, impactMarker);
}
},
onAnimationComplete: () => {
if (!isFrost) {
disposeCombatObject(scene, impactMarker);
}
finishImpact();
},
});
});
pendingTimers.push(timer);
});
}
@@ -315,7 +350,7 @@ function resolveWorldEffectSizeScale() {
}
function resolveWorldEffectVisualScale(zone) {
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.VISUAL_SCALE) || 1);
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.IMPACT_VISUAL_SCALE) || 1);
return baseScale * Math.max(0.1, Number(zone?.sizeScale) || 1);
}
@@ -348,14 +383,44 @@ function createFallTrajectory(zone) {
};
}
function createEffectZone(target, sizeScale = 1) {
const areaTiles = Math.max(
1,
(Number(WORLD_EFFECT.AREA_TILES) || 1) * Math.max(0.1, Number(sizeScale) || 1),
function createBarrageImpactZones(targetZone) {
const minimumCount = Math.max(1, Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MIN) || 1));
const maximumCount = Math.max(
minimumCount,
Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MAX) || minimumCount),
);
const count = Phaser.Math.Between(minimumCount, maximumCount);
return Array.from({ length: count }, () => createBarrageImpactZone(targetZone));
}
function createBarrageImpactZone(targetZone) {
const sizeScale = resolveWorldEffectSizeScale();
const maximumImpactTiles = Math.max(1, targetZone.areaTiles - 1);
const impactTiles = Math.min(
maximumImpactTiles,
Math.max(
1,
Math.round((Number(WORLD_EFFECT.IMPACT_AREA_TILES) || 1) * sizeScale),
),
);
const impactSize = impactTiles * ARENA.TILE_SIZE;
const halfImpactSize = impactSize / 2;
const minimumX = targetZone.bounds.left + halfImpactSize;
const maximumX = targetZone.bounds.right - halfImpactSize;
const minimumY = targetZone.bounds.top + halfImpactSize;
const maximumY = targetZone.bounds.bottom - halfImpactSize;
return createEffectZone(
randomBetween(minimumX, maximumX),
randomBetween(minimumY, maximumY),
impactTiles,
sizeScale,
);
}
function createEffectZone(centerX, centerY, areaTiles, sizeScale = 1) {
const size = ARENA.TILE_SIZE * areaTiles;
const centerX = target.body?.center.x ?? target.x;
const centerY = target.body?.center.y ?? target.y;
return {
areaTiles,
@@ -367,6 +432,66 @@ function createEffectZone(target, sizeScale = 1) {
};
}
function createSummedAreaTable(tileCounts) {
const sums = Array.from(
{ length: ARENA.GRID_SIZE + 1 },
() => Array(ARENA.GRID_SIZE + 1).fill(0),
);
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
sums[row + 1][column + 1] =
tileCounts[row][column]
+ sums[row][column + 1]
+ sums[row + 1][column]
- sums[row][column];
}
}
return sums;
}
function sumArea(sums, column, row, areaTiles) {
const bottom = row + areaTiles;
const right = column + areaTiles;
return (
sums[bottom][right]
- sums[row][right]
- sums[bottom][column]
+ sums[row][column]
);
}
function resolveTileCount(value, maximum) {
return Phaser.Math.Clamp(Math.round(Number(value) || 1), 1, maximum);
}
function resolveImpactStaggerMs() {
return Math.max(0, Math.round(Number(WORLD_EFFECT.IMPACT_STAGGER_MS) || 0));
}
function resolveWarningDurationMs() {
return Math.max(0, Math.round(Number(WORLD_EFFECT.WARNING_DURATION_MS) || 0));
}
function randomBetween(minimum, maximum) {
if (maximum <= minimum) {
return minimum;
}
return Phaser.Math.FloatBetween(minimum, maximum);
}
function hideZoneMarker(scene, marker) {
if (!marker?.active) {
return;
}
scene.tweens.killTweensOf(marker);
marker.setVisible(false);
}
function createZoneMarker(scene, zone, color) {
const marker = scene.add.graphics().setDepth(1.5);
const { x, y, width, height } = zone.bounds;
+30 -35
View File
@@ -4,10 +4,8 @@ const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
export function createMatchSetup(
names,
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
) {
const baseTeamSize = resolveTeamSize(requestedTeamSize);
const teams = names.map((rawName, index) => {
const match = rawName.match(NAME_MULTIPLIER_REGEX);
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
@@ -18,19 +16,23 @@ export function createMatchSetup(
id: `team-${index + 1}`,
label,
multiplier,
size: baseTeamSize * multiplier,
size: multiplier,
startingZoneCount: Math.ceil(multiplier / SPAWN.FIGHTERS_PER_STARTING_ZONE),
};
});
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
if (totalFighters > SPAWN.MAX_FIGHTER_COUNT) {
throw new FighterCountLimitError(totalFighters);
}
const startingZones =
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
? createStartingZones(teams)
: [];
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
const spawns = createSpawnPoints(
totalFighters,
baseTeamSize,
requestedSpawnPlacement,
startingZones,
);
@@ -55,29 +57,24 @@ export function createMatchSetup(
};
}
export class FighterCountLimitError extends Error {
constructor(fighterCount) {
super(`Requested fighter count exceeds the ${SPAWN.MAX_FIGHTER_COUNT} limit.`);
this.fighterCount = fighterCount;
this.maxFighterCount = SPAWN.MAX_FIGHTER_COUNT;
}
}
export function matchStatusText(teams) {
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
const teamSizes = new Set(teams.map((team) => team.size));
const teamSizeText = teamSizes.size === 1 ? `팀당 ${teams[0]?.size ?? 0}` : "팀별 가변 인원";
const labels = teams.map((team) => `${team.label} ${team.size}`).join(" / ");
return `${teams.length}팀 | ${teamSizeText} |${totalFighters}명 출전 | ${labels}`;
return `${teams.length}팀 | 총 ${totalFighters}명 출전 | ${labels}`;
}
function createTeams(playerCount, teamSize) {
const teamCount = Math.ceil(playerCount / teamSize);
return Array.from({ length: teamCount }, (_, index) => ({
color: TEAM.getColor(index, teamCount),
id: `team-${index + 1}`,
label: `Team ${index + 1}`,
size: Math.min(teamSize, playerCount - index * teamSize),
}));
}
function createSpawnPoints(totalCount, teamSize, requestedSpawnPlacement, startingZones) {
function createSpawnPoints(totalCount, requestedSpawnPlacement, startingZones) {
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(startingZones, teamSize);
return createStartingZoneSpawnPoints(startingZones);
}
return createRandomSpawnPoints(totalCount);
@@ -87,30 +84,36 @@ function createRandomSpawnPoints(count) {
return createSpawnPointsFromSlots(createSpawnSlots(), count);
}
function createStartingZoneSpawnPoints(startingZones, teamSize) {
function createStartingZoneSpawnPoints(startingZones) {
const fallbackSlots = createSpawnSlots();
return startingZones.flatMap((zone) => {
const zoneSlots = createSpawnSlots(zone);
return createSpawnPointsFromSlots(zoneSlots.length > 0 ? zoneSlots : fallbackSlots, teamSize);
return createSpawnPointsFromSlots(
zoneSlots.length > 0 ? zoneSlots : fallbackSlots,
zone.spawnCount,
);
});
}
function createStartingZones(teams) {
const totalZonesNeeded = teams.reduce((sum, team) => sum + (team.multiplier || 1), 0);
const totalZonesNeeded = teams.reduce((sum, team) => sum + team.startingZoneCount, 0);
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
let layoutIndex = 0;
return teams.flatMap((team) => {
const multiplier = team.multiplier || 1;
let remainingFighters = team.size;
const teamZones = [];
for (let i = 0; i < multiplier; i++) {
for (let i = 0; i < team.startingZoneCount; i++) {
const spawnCount = Math.min(SPAWN.FIGHTERS_PER_STARTING_ZONE, remainingFighters);
teamZones.push({
...layout[layoutIndex++],
color: team.color,
spawnCount,
teamId: team.id,
});
remainingFighters -= spawnCount;
}
return teamZones;
@@ -213,14 +216,6 @@ function startingZonesOverlap(left, right) {
);
}
function resolveTeamSize(requestedTeamSize) {
return clamp(
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
1,
SPAWN.MAX_TEAM_SIZE,
);
}
function spawnJitter() {
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
}
+9 -3
View File
@@ -56,6 +56,10 @@ function startConfiguredMatch(matchConfig) {
return;
}
if (!arenaScene.startMatch(matchConfig)) {
return;
}
appNode?.classList.remove("match-ended");
appNode?.classList.add("match-live");
@@ -65,14 +69,15 @@ function startConfiguredMatch(matchConfig) {
openOptionsDrawer({ focus: false });
}
arenaScene.startMatch(matchConfig);
syncPauseButton();
}
function getPresentationMatchConfig() {
return {
names: Array.from({ length: SPAWN.PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
teamSize: SPAWN.PRESENTATION_TEAM_SIZE,
names: Array.from(
{ length: SPAWN.PRESENTATION_TEAM_COUNT },
(_, index) => `Player ${index + 1}*${SPAWN.PRESENTATION_TEAM_SIZE}`,
),
};
}
@@ -169,6 +174,7 @@ window.addEventListener("keydown", (event) => {
const arenaScene = new ArenaScene({
getInitialMatchConfig: getPresentationMatchConfig,
onMatchEnd: handleMatchEnd,
setPlayerNamesWarning: matchForm.setPlayerNamesWarning,
setStatus: matchForm.setStatus,
});
-5
View File
@@ -100,11 +100,6 @@
padding-block: 9px;
}
#app.match-live .team-size-number {
width: 64px;
min-width: 64px;
}
#app.match-live .spawn-placement-option span {
min-height: 36px;
padding: 6px;
+67 -15
View File
@@ -462,21 +462,6 @@ legend {
text-transform: uppercase;
}
.team-size-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.team-size-number {
width: 88px;
min-width: 88px;
padding-inline: 10px;
text-align: center;
font-weight: 900;
}
label {
color: #ead8ad;
font-size: 0.92rem;
@@ -500,6 +485,73 @@ textarea {
line-height: 1.45;
}
textarea[aria-invalid="true"] {
border-color: #d9a628;
box-shadow: 0 0 0 1px rgb(217 166 40 / 0.34);
}
.player-names-warning {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 9px;
margin: 0;
border: 1px solid rgb(219 168 45 / 0.72);
border-radius: 8px;
padding: 10px;
background: linear-gradient(135deg, rgb(88 67 17 / 0.7), rgb(43 37 19 / 0.92));
box-shadow: inset 3px 0 0 #dba82d;
line-height: 1.35;
}
.player-names-warning[hidden] {
display: none;
}
.player-names-warning__badge {
grid-row: span 2;
align-self: start;
color: #f4bd37;
font-size: 1.2rem;
font-weight: 900;
line-height: 1;
}
.player-names-warning__title {
color: #ffd565;
font-size: 0.92rem;
font-weight: 900;
}
.player-names-warning__detail {
grid-column: 2;
margin: 0;
color: #f1dfaa;
font-size: 0.82rem;
font-weight: 500;
}
.player-names-warning__count {
color: #ffd04f;
font-size: 1rem;
font-weight: 900;
}
.player-names-warning__limit {
color: #f7e0a0;
font-size: 0.94rem;
font-weight: 900;
}
.player-names-warning__reason {
grid-column: 1 / -1;
margin: 5px 0 0;
border-top: 1px solid rgb(219 168 45 / 0.25);
padding-top: 7px;
color: #d7c489;
font-size: 0.76rem;
}
input[type="range"] {
width: 100%;
accent-color: #e3b24f;
+28 -77
View File
@@ -3,52 +3,51 @@ import { FIGHTER, SPAWN } from "../constants.js";
const STORAGE_KEYS = {
names: "arena.match.playerNames",
spawnPlacement: "arena.match.spawnPlacement",
teamSize: "arena.match.teamSize",
};
export function createMatchForm() {
const form = getElement("#fighter-form");
const namesInput = getElement("#player-names");
const namesWarningNode = getElement("#player-names-warning");
const namesWarningTitleNode = getElement("[data-player-names-warning-title]");
const namesWarningCountNode = getElement("[data-player-names-warning-count]");
const namesWarningLimitNode = getElement("[data-player-names-warning-limit]");
const namesWarningReasonNode = getElement("[data-player-names-warning-reason]");
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 teamSizeNumberInput = getElement("#team-size-value");
const setPlayerNamesWarning = (warning = null) => {
const hasWarning = Boolean(warning);
applyTeamSizeInputLimits(teamSizeInput, teamSizeNumberInput);
if (!hasWarning) {
namesWarningNode.hidden = true;
namesInput.removeAttribute("aria-invalid");
return;
}
namesWarningTitleNode.textContent = warning.title;
namesWarningCountNode.textContent = warning.fighterCount.toLocaleString("ko-KR");
namesWarningLimitNode.textContent = warning.maxFighterCount.toLocaleString("ko-KR");
namesWarningReasonNode.textContent = warning.reason ?? "";
namesWarningReasonNode.hidden = !warning.reason;
namesWarningNode.hidden = false;
namesInput.setAttribute("aria-invalid", "true");
};
const readMatchConfig = () => ({
names: nicknameValues(namesInput.value),
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
teamSize: Number(teamSizeInput.value),
});
restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
restoreSavedMatchSettings(namesInput, spawnPlacementInputs);
namesInput.addEventListener("input", () => {
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
});
teamSizeInput.addEventListener("input", () => {
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);
setPlayerNamesWarning();
saveMatchSettings(namesInput, spawnPlacementInputs);
});
spawnPlacementInputs.forEach((input) => {
input.addEventListener("change", () => {
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
saveMatchSettings(namesInput, spawnPlacementInputs);
});
});
@@ -60,6 +59,7 @@ export function createMatchForm() {
});
},
readMatchConfig,
setPlayerNamesWarning,
setStatus(message) {
if (statusNode) {
statusNode.setAttribute("aria-hidden", "false");
@@ -102,34 +102,7 @@ function nicknameValues(value) {
.filter(Boolean);
}
function applyTeamSizeInputLimits(...inputs) {
inputs.forEach((input) => {
input.min = "1";
input.max = String(SPAWN.MAX_TEAM_SIZE);
input.step = "1";
input.value = String(SPAWN.DEFAULT_TEAM_SIZE);
});
}
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,
spawnPlacementInputs,
teamSizeInput,
teamSizeNumberInput,
) {
function restoreSavedMatchSettings(namesInput, spawnPlacementInputs) {
const storage = getLocalStorage();
if (!storage) {
@@ -139,27 +112,18 @@ function restoreSavedMatchSettings(
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) {
namesInput.value = savedNames;
}
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
syncTeamSizeInputs(
teamSizeInput,
teamSizeNumberInput,
normalizedTeamSize || teamSizeInput.value,
);
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
} catch {
// Storage may be unavailable in private or restricted browser contexts.
}
}
function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
function saveMatchSettings(namesInput, spawnPlacementInputs) {
const storage = getLocalStorage();
if (!storage) {
@@ -169,7 +133,6 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, 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.
}
@@ -189,18 +152,6 @@ function setSpawnPlacement(inputs, value) {
}
}
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;