feat: implement dense-area meteor barrage, large-battle optimization, and direct fighter count input
This commit is contained in:
+20
-13
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user