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
+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),