feat: implement nickname multiplier (*N) for variable team sizes and sudden death system with configurable world effects
This commit is contained in:
+14
-5
@@ -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 도메인
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user