feat: implement nickname multiplier (*N) for variable team sizes and sudden death system with configurable world effects

This commit is contained in:
2026-05-25 21:24:22 +09:00
parent f9b548f9cd
commit 743b2a75f5
8 changed files with 199 additions and 45 deletions
+14 -5
View File
@@ -21,7 +21,7 @@ export const FIGHTER = {
HITBOX_HEIGHT: 20,
HITBOX_OFFSET_X: 39,
HITBOX_OFFSET_Y: 40,
NICKNAME_LENGTH: 18,
NICKNAME_LENGTH: 24,
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
ANIMATION_OPTIONS: {
attack: { frameRate: 15, repeat: 0 },
@@ -117,18 +117,27 @@ export const PROJECTILE = {
// 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = {
INTERVAL: 4000,
AREA_TILES: 5,
AREA_TILES: 15,
FRAMES: 7,
FRAME_RATE: 14,
FALL_DURATION: 920,
FALL_TRAVEL_TILES: 8,
VISUAL_SCALE: 12,
METEOR_DAMAGE: 80,
FROST_DAMAGE: 40,
VISUAL_SCALE: 50,
// 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,
FROST_STUN_TINT: 0x82e9ff,
FROST_DURATION: 20000,
FROST_SPEED_MULTIPLIER: 0.55,
SUDDEN_DEATH: {
ENABLED: false,
TRIGGER_MS: 10000,
INTERVAL_MS: 2000,
FORCE_FROST: false,
},
};
// 7. CAMERA 도메인
+97 -9
View File
@@ -57,11 +57,27 @@ export function startWorldEffects(scene) {
return;
}
scene.worldEffectTimer = scene.time.addEvent({
callback: () => triggerWorldEffect(scene),
delay: WORLD_EFFECT.INTERVAL,
loop: true,
});
scene.matchStartedAt = scene.time.now;
scene.isSuddenDeath = false;
const scheduleNext = () => {
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;
if (isSuddenDeath && !scene.isSuddenDeath) {
scene.isSuddenDeath = true;
}
scene.worldEffectTimer = scene.time.delayedCall(delay, () => {
triggerWorldEffect(scene);
scheduleNext();
});
};
scheduleNext();
}
export function clearWorldEffects(scene) {
@@ -69,6 +85,8 @@ export function clearWorldEffects(scene) {
scene.worldEffectTimer = null;
scene.worldEffectZones?.clear();
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
scene.matchStartedAt = null;
scene.isSuddenDeath = false;
scene.fighters?.forEach((fighter) => {
fighter.worldEffectSpeedMultiplier = 1;
@@ -106,15 +124,85 @@ function triggerWorldEffect(scene) {
return;
}
const target = livingFighters[Phaser.Math.Between(0, livingFighters.length - 1)];
const target = chooseWorldEffectTarget(livingFighters);
const zone = createEffectZone(target);
if (Phaser.Math.Between(0, 1) === 0) {
spawnMeteor(scene, zone);
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
spawnFrostZone(scene, zone);
return;
}
spawnFrostZone(scene, zone);
spawnMeteor(scene, zone);
}
export function chooseWorldEffectTarget(livingFighters) {
if (livingFighters.length === 0) {
return null;
}
const dominanceMultiplier = Math.max(
0,
Number(WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER) || 0,
);
if (dominanceMultiplier === 0) {
return randomEntry(livingFighters);
}
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);
});
return randomEntry(weightedEntry(teamPools).fighters);
}
function resolvePurchasedWeight(team) {
return Math.max(1, Number(team?.multiplier) || 1);
}
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;
}
}
return entries[entries.length - 1];
}
function randomEntry(entries) {
return entries[Phaser.Math.Between(0, entries.length - 1)];
}
function spawnMeteor(scene, zone) {
+44 -22
View File
@@ -1,37 +1,48 @@
import { ARENA, SPAWN, TEAM } from "../../constants.js";
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
export function createMatchSetup(
names,
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
) {
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE));
const teams = names.map((name, index) => ({
color: TEAM.getColor(index, names.length),
id: `team-${index + 1}`,
label: name,
size: teamSize,
}));
const baseTeamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE));
const teams = names.map((rawName, index) => {
const match = rawName.match(NAME_MULTIPLIER_REGEX);
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
const label = match ? rawName.replace(NAME_MULTIPLIER_REGEX, "") : rawName;
return {
color: TEAM.getColor(index, names.length),
id: `team-${index + 1}`,
label,
multiplier,
size: baseTeamSize * multiplier,
};
});
const startingZones =
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
? createStartingZones(teams)
: [];
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
const spawns = createSpawnPoints(
names.length,
teamSize,
totalFighters,
baseTeamSize,
requestedSpawnPlacement,
startingZones,
);
const fighters = [];
names.forEach((name, teamIndex) => {
for (let i = 0; i < teamSize; i++) {
const globalIndex = teamIndex * teamSize + i;
teams.forEach((team) => {
for (let i = 0; i < team.size; i++) {
const globalIndex = fighters.length;
fighters.push({
...spawns[globalIndex],
name: name,
team: teams[teamIndex],
name: team.label,
team: team,
teamIndex: i,
});
}
@@ -64,12 +75,12 @@ function createTeams(playerCount, teamSize) {
}));
}
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement, startingZones) {
function createSpawnPoints(totalCount, teamSize, requestedSpawnPlacement, startingZones) {
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(startingZones, teamSize);
}
return createRandomSpawnPoints(teamCount * teamSize);
return createRandomSpawnPoints(totalCount);
}
function createRandomSpawnPoints(count) {
@@ -86,13 +97,24 @@ function createStartingZoneSpawnPoints(startingZones, teamSize) {
}
function createStartingZones(teams) {
const layout = shuffle(createStartingZoneLayout(teams.length));
const totalZonesNeeded = teams.reduce((sum, team) => sum + (team.multiplier || 1), 0);
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
return teams.map((team, index) => ({
...layout[index],
color: team.color,
teamId: team.id,
}));
let layoutIndex = 0;
return teams.flatMap((team) => {
const multiplier = team.multiplier || 1;
const teamZones = [];
for (let i = 0; i < multiplier; i++) {
teamZones.push({
...layout[layoutIndex++],
color: team.color,
teamId: team.id,
});
}
return teamZones;
});
}
function createStartingZoneLayout(teamCount) {