feat: implement elite fighter compression for large battles with randomized scaling and percentage-based damage
This commit is contained in:
+25
-3
@@ -71,6 +71,23 @@ export const FIGHTER = {
|
||||
effectHitDelay: 160,
|
||||
},
|
||||
},
|
||||
ELITE: {
|
||||
TYPE: "melee",
|
||||
STACK_SIZE: 100,
|
||||
VISUAL_SCALE_MULTIPLIER: 5,
|
||||
HP_BONUS_RATIO: 1,
|
||||
ATTACK_RANGE_MULTIPLIER: 1.5,
|
||||
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
|
||||
ATTACK_DAMAGE_STACK_EXPONENT: 0.5,
|
||||
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
|
||||
ATTACK_SPEED_STACK_EXPONENT: 0,
|
||||
MOVE_SPEED_BONUS_MULTIPLIER: 1,
|
||||
MOVE_SPEED_STACK_EXPONENT: 0,
|
||||
RANDOMIZED_COMPRESSION: {
|
||||
MIN_TEAM_SIZE: 100,
|
||||
ELITE_BLOCK_PROBABILITY: 0.6,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PERFORMANCE = {
|
||||
@@ -95,7 +112,7 @@ export const SPAWN = {
|
||||
},
|
||||
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
|
||||
MAX_FIGHTER_COUNT: 8000,
|
||||
FIGHTERS_PER_STARTING_ZONE: 100,
|
||||
FIGHTERS_PER_STARTING_ZONE: 200,
|
||||
STARTING_ZONE_RADIUS: 2,
|
||||
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||
@@ -106,12 +123,15 @@ export const SPAWN = {
|
||||
|
||||
// 4. COMBAT 도메인
|
||||
export const COMBAT = {
|
||||
KILL_REWARD_ENABLED: false,
|
||||
KILL_HEALTH_RECOVERY_RATIO: 0.3,
|
||||
KILL_HEAL_EFFECT_FRAMES: 4,
|
||||
KILL_HEAL_EFFECT_FRAME_RATE: 12,
|
||||
KILL_GROWTH_MULTIPLIER: 1.25,
|
||||
KILL_GROWTH_MAX_MULTIPLIER: 5,
|
||||
KILL_GROWTH_TWEEN_DURATION: 180,
|
||||
CRITICAL_DAMAGE_PERCENT: 0.1,
|
||||
NORMAL_CRITICAL_DAMAGE_MULTIPLIER: 2,
|
||||
// 최종교전 슬로우모션 설정
|
||||
FINAL_SLOW_MOTION_ENABLED: false,
|
||||
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
|
||||
@@ -151,7 +171,9 @@ export const WORLD_EFFECT = {
|
||||
METEOR_SHAKE_DURATION_MS: 150,
|
||||
METEOR_SHAKE_INTENSITY: 0.004,
|
||||
METEOR_DAMAGE: 90,
|
||||
METEOR_DAMAGE_PERCENT: 0.4,
|
||||
FROST_DAMAGE: 45,
|
||||
FROST_DAMAGE_PERCENT: 0.2,
|
||||
FROST_STUN_DURATION: 2000,
|
||||
FROST_STUN_TINT: 0x82e9ff,
|
||||
FROST_DURATION: 2000,
|
||||
@@ -172,7 +194,7 @@ export const CAMERA = {
|
||||
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||
METEOR_FOCUS_ENABLED: false,
|
||||
METEOR_FOCUS_ZOOM: 2,
|
||||
SPECTATOR_LERP: 0.1,
|
||||
SPECTATOR_LERP: 0.01,
|
||||
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||
METEOR_FOCUS_HOLD_DURATION: 1200,
|
||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
||||
@@ -180,7 +202,7 @@ export const CAMERA = {
|
||||
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 500,
|
||||
SPECTATOR_LATE_FIGHT_ZOOM: 2,
|
||||
SELECTED_FIGHTER_ZOOM: 2,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
syncFighterHud,
|
||||
} from "../fighter/fighterFactory.js";
|
||||
import { fighterManifest } from "../fighter/fighterManifest.js";
|
||||
import { pickFighters } from "../fighter/fighterSelection.js";
|
||||
import { pickFightersForSetups } from "../fighter/fighterSelection.js";
|
||||
import {
|
||||
createMatchSetup,
|
||||
FighterCountLimitError,
|
||||
@@ -202,7 +202,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
|
||||
const matchSkins = pickFightersForSetups(fighterManifest, matchSetup.fighters);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
|
||||
expandSpawnMultipliers: !silent,
|
||||
});
|
||||
@@ -357,7 +357,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
const species = normalizeSpecies(fighter?.skin?.species);
|
||||
this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + 1;
|
||||
const deathCount = Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
|
||||
this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + deathCount;
|
||||
}
|
||||
|
||||
showBattleDeathNotice() {
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
} from "../../constants.js";
|
||||
|
||||
export function getSpectatorState(livingFighters) {
|
||||
const livingFighterCount = livingFighters.length;
|
||||
const livingFighterCount = livingFighters.reduce(
|
||||
(count, fighter) => count + representedFighterCount(fighter),
|
||||
0,
|
||||
);
|
||||
const teamSummaries = getLivingTeamSummaries(livingFighters);
|
||||
|
||||
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
||||
@@ -48,7 +51,7 @@ export function getLivingTeamSummaries(livingFighters) {
|
||||
teamId,
|
||||
};
|
||||
|
||||
summary.count += 1;
|
||||
summary.count += representedFighterCount(fighter);
|
||||
summaries.set(teamId, summary);
|
||||
});
|
||||
|
||||
@@ -70,22 +73,28 @@ export function averageFighterPosition(fighters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = fighters.reduce(
|
||||
const weighted = fighters.reduce(
|
||||
(position, fighter) => {
|
||||
const point = fighterCameraPoint(fighter);
|
||||
position.x += point.x;
|
||||
position.y += point.y;
|
||||
const weight = representedFighterCount(fighter);
|
||||
position.count += weight;
|
||||
position.x += point.x * weight;
|
||||
position.y += point.y * weight;
|
||||
return position;
|
||||
},
|
||||
{ x: 0, y: 0 },
|
||||
{ count: 0, x: 0, y: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
x: total.x / fighters.length,
|
||||
y: total.y / fighters.length,
|
||||
x: weighted.x / weighted.count,
|
||||
y: weighted.y / weighted.count,
|
||||
};
|
||||
}
|
||||
|
||||
function representedFighterCount(fighter) {
|
||||
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
|
||||
}
|
||||
|
||||
export function fighterCameraPoint(fighter) {
|
||||
const target = fighter?.body?.center ?? fighter;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
COMBAT,
|
||||
PERFORMANCE,
|
||||
PROJECTILE,
|
||||
WORLD_EFFECT,
|
||||
} from "../../constants.js";
|
||||
import {
|
||||
getAttackSpeedMultiplier,
|
||||
@@ -255,12 +256,12 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
|
||||
}
|
||||
|
||||
const attackerStats = combatStatsFor(attacker);
|
||||
defender.hp = isCritical
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
defender.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax),
|
||||
);
|
||||
const normalDamage = Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax);
|
||||
const damage = isCritical
|
||||
? criticalDamageFor(defender, normalDamage)
|
||||
: normalDamage;
|
||||
|
||||
defender.hp = Math.max(0, defender.hp - damage);
|
||||
defender.body.setVelocity(0, 0);
|
||||
|
||||
if (defender.hp === 0) {
|
||||
@@ -272,12 +273,12 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
|
||||
playAnimation(defender, "hurt");
|
||||
}
|
||||
|
||||
export function applyWorldEffectDamage(scene, defender, damage) {
|
||||
export function applyWorldEffectDamage(scene, defender, effectType) {
|
||||
if (scene.matchOver || !defender?.active || defender.isDead) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedDamage = Math.max(0, Math.round(Number(damage) || 0));
|
||||
const resolvedDamage = worldEffectDamageFor(defender, effectType);
|
||||
|
||||
if (resolvedDamage === 0) {
|
||||
return false;
|
||||
@@ -296,6 +297,38 @@ export function applyWorldEffectDamage(scene, defender, damage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function criticalDamageFor(defender, normalDamage) {
|
||||
if (!defender.isElite) {
|
||||
return normalDamage * COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER;
|
||||
}
|
||||
|
||||
const percentageDamage = Math.ceil(
|
||||
(defender.maxHp ?? 1) * COMBAT.CRITICAL_DAMAGE_PERCENT,
|
||||
);
|
||||
|
||||
return Math.max(normalDamage, percentageDamage);
|
||||
}
|
||||
|
||||
function worldEffectDamageFor(defender, effectType) {
|
||||
const fixedDamage = effectType === "meteor"
|
||||
? WORLD_EFFECT.METEOR_DAMAGE
|
||||
: effectType === "frost"
|
||||
? WORLD_EFFECT.FROST_DAMAGE
|
||||
: 0;
|
||||
|
||||
if (!defender.isElite) {
|
||||
return Math.max(0, Math.round(Number(fixedDamage) || 0));
|
||||
}
|
||||
|
||||
const percentage = effectType === "meteor"
|
||||
? WORLD_EFFECT.METEOR_DAMAGE_PERCENT
|
||||
: effectType === "frost"
|
||||
? WORLD_EFFECT.FROST_DAMAGE_PERCENT
|
||||
: 0;
|
||||
|
||||
return Math.max(0, Math.ceil((defender.maxHp ?? 1) * percentage));
|
||||
}
|
||||
|
||||
function spawnCriticalHitLabel(scene, defender) {
|
||||
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE);
|
||||
const label = scene.add
|
||||
@@ -425,7 +458,10 @@ function killFighter(defender, winner, onWinner) {
|
||||
winner.body.setVelocity(0, 0);
|
||||
playAnimation(winner, "idle");
|
||||
winner.scene.recordKill?.(winner, defender);
|
||||
applyKillReward(winner);
|
||||
|
||||
if (COMBAT.KILL_REWARD_ENABLED) {
|
||||
applyKillReward(winner);
|
||||
}
|
||||
} else {
|
||||
defender.scene.recordDeath?.(defender);
|
||||
}
|
||||
@@ -838,6 +874,11 @@ function fighterAttackSpeedMultiplier(fighter) {
|
||||
getAttackSpeedMultiplier()
|
||||
* (fighter.killRewardMultiplier ?? 1)
|
||||
* (fighter.worldEffectSpeedMultiplier ?? 1)
|
||||
* eliteSpeedMultiplier(
|
||||
fighter,
|
||||
FIGHTER.ELITE.ATTACK_SPEED_BONUS_MULTIPLIER,
|
||||
FIGHTER.ELITE.ATTACK_SPEED_STACK_EXPONENT,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,9 +887,24 @@ function fighterMovementSpeedMultiplier(fighter) {
|
||||
getMovementSpeedMultiplier()
|
||||
* (fighter.killRewardMultiplier ?? 1)
|
||||
* (fighter.worldEffectSpeedMultiplier ?? 1)
|
||||
* eliteSpeedMultiplier(
|
||||
fighter,
|
||||
FIGHTER.ELITE.MOVE_SPEED_BONUS_MULTIPLIER,
|
||||
FIGHTER.ELITE.MOVE_SPEED_STACK_EXPONENT,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function eliteSpeedMultiplier(fighter, bonusMultiplier, stackExponent) {
|
||||
if (!fighter.isElite) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const stackCount = Math.max(1, Number(fighter.stackCount) || 1);
|
||||
const stackedMultiplier = Math.pow(stackCount, stackExponent);
|
||||
return 1 + bonusMultiplier * (stackedMultiplier - 1);
|
||||
}
|
||||
|
||||
function combatStatsFor(fighter) {
|
||||
return fighter.combatStats ?? getFighterStats(fighter.skin);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export function findDensestWorldEffectZone(livingFighters) {
|
||||
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);
|
||||
|
||||
tileCounts[row][column] += 1;
|
||||
tileCounts[row][column] += representedFighterCount(fighter);
|
||||
});
|
||||
|
||||
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
|
||||
@@ -195,7 +195,7 @@ function randomEntry(entries) {
|
||||
function spawnMeteor(scene, zone) {
|
||||
spawnWorldEffectBarrage(scene, zone, {
|
||||
color: METEOR_ZONE_COLOR,
|
||||
damage: WORLD_EFFECT.METEOR_DAMAGE,
|
||||
effectType: "meteor",
|
||||
effectKey: METEOR_EFFECT_KEY,
|
||||
});
|
||||
}
|
||||
@@ -203,7 +203,7 @@ function spawnMeteor(scene, zone) {
|
||||
function spawnFrostZone(scene, zone) {
|
||||
spawnWorldEffectBarrage(scene, zone, {
|
||||
color: FROST_ZONE_COLOR,
|
||||
damage: WORLD_EFFECT.FROST_DAMAGE,
|
||||
effectType: "frost",
|
||||
effectKey: FROST_EFFECT_KEY,
|
||||
isFrost: true,
|
||||
});
|
||||
@@ -212,7 +212,7 @@ function spawnFrostZone(scene, zone) {
|
||||
function spawnWorldEffectBarrage(
|
||||
scene,
|
||||
targetZone,
|
||||
{ color, damage, effectKey, isFrost = false },
|
||||
{ color, effectType, effectKey, isFrost = false },
|
||||
) {
|
||||
const matchId = scene.matchId;
|
||||
const targetMarker = createZoneMarker(scene, targetZone, color);
|
||||
@@ -265,7 +265,7 @@ function spawnWorldEffectBarrage(
|
||||
resolveImpactDamage(
|
||||
scene,
|
||||
impactZone,
|
||||
damage,
|
||||
effectType,
|
||||
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
||||
);
|
||||
|
||||
@@ -525,13 +525,13 @@ function createZoneMarker(scene, zone, color) {
|
||||
return marker;
|
||||
}
|
||||
|
||||
function resolveImpactDamage(scene, zone, damage, onSurvivor) {
|
||||
function resolveImpactDamage(scene, zone, effectType, onSurvivor) {
|
||||
let deathCount = 0;
|
||||
|
||||
scene.fighters
|
||||
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
|
||||
.forEach((fighter) => {
|
||||
if (applyWorldEffectDamage(scene, fighter, damage)) {
|
||||
if (applyWorldEffectDamage(scene, fighter, effectType)) {
|
||||
deathCount += 1;
|
||||
return;
|
||||
}
|
||||
@@ -606,6 +606,10 @@ function containsFighter(zone, fighter) {
|
||||
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
|
||||
}
|
||||
|
||||
function representedFighterCount(fighter) {
|
||||
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
|
||||
}
|
||||
|
||||
function isLiveMatch(scene, matchId = scene.matchId) {
|
||||
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,20 @@ const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
|
||||
|
||||
export function createFighter(
|
||||
scene,
|
||||
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
|
||||
{
|
||||
canSplitOnDeath = true,
|
||||
faceLeft,
|
||||
hp,
|
||||
isElite = false,
|
||||
maxHp,
|
||||
name,
|
||||
skin,
|
||||
stackCount = 1,
|
||||
team,
|
||||
teamIndex,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
) {
|
||||
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||
|
||||
@@ -25,14 +38,40 @@ export function createFighter(
|
||||
: fighterSheetKey(skin, "idle");
|
||||
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
|
||||
const displayName = name || team.label;
|
||||
const combatStats = getFighterStats(skin);
|
||||
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
|
||||
const baseCombatStats = getFighterStats(skin);
|
||||
const resolvedStackCount = Math.max(1, Math.round(Number(stackCount) || 1));
|
||||
const resolvedIsElite = Boolean(isElite);
|
||||
const attackDamageMultiplier = resolvedIsElite
|
||||
? eliteBonusMultiplier(
|
||||
resolvedStackCount,
|
||||
FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER,
|
||||
FIGHTER.ELITE.ATTACK_DAMAGE_STACK_EXPONENT,
|
||||
)
|
||||
: 1;
|
||||
const visualScale = resolvedIsElite
|
||||
? FIGHTER.SCALE * FIGHTER.ELITE.VISUAL_SCALE_MULTIPLIER
|
||||
: FIGHTER.SCALE;
|
||||
const rangeBonus = resolvedIsElite
|
||||
? (visualScale - FIGHTER.SCALE) * (FIGHTER.HITBOX_WIDTH / 2)
|
||||
: 0;
|
||||
const combatStats = {
|
||||
...baseCombatStats,
|
||||
attackRange: resolvedIsElite
|
||||
? baseCombatStats.attackRange * FIGHTER.ELITE.ATTACK_RANGE_MULTIPLIER + rangeBonus
|
||||
: baseCombatStats.attackRange,
|
||||
damageMax: baseCombatStats.damageMax * attackDamageMultiplier,
|
||||
damageMin: baseCombatStats.damageMin * attackDamageMultiplier,
|
||||
};
|
||||
const hpMultiplier = resolvedIsElite
|
||||
? resolvedStackCount * FIGHTER.ELITE.HP_BONUS_RATIO
|
||||
: resolvedStackCount;
|
||||
const resolvedMaxHp = Math.max(1, Math.round((maxHp ?? baseCombatStats.maxHp) * hpMultiplier));
|
||||
const resolvedHp = Math.min(
|
||||
resolvedMaxHp,
|
||||
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
|
||||
);
|
||||
|
||||
fighter.setScale(FIGHTER.SCALE);
|
||||
fighter.setScale(visualScale);
|
||||
fighter.setName(displayName);
|
||||
fighter.setDepth(FIGHTER.DEPTH);
|
||||
fighter.setAlpha(1);
|
||||
@@ -54,11 +93,13 @@ export function createFighter(
|
||||
fighter.skin = skin;
|
||||
fighter.combatStats = combatStats;
|
||||
fighter.fighterName = displayName;
|
||||
fighter.isElite = resolvedIsElite;
|
||||
fighter.stackCount = resolvedStackCount;
|
||||
fighter.team = team;
|
||||
fighter.teamIndex = teamIndex;
|
||||
fighter.baseScaleX = FIGHTER.SCALE;
|
||||
fighter.baseScaleY = FIGHTER.SCALE;
|
||||
fighter.canSplitOnDeath = canSplitOnDeath;
|
||||
fighter.baseScaleX = visualScale;
|
||||
fighter.baseScaleY = visualScale;
|
||||
fighter.canSplitOnDeath = canSplitOnDeath && !resolvedIsElite;
|
||||
fighter.isSelected = false;
|
||||
fighter.killCount = 0;
|
||||
fighter.killRewardMultiplier = 1;
|
||||
@@ -93,6 +134,11 @@ export function createFighter(
|
||||
return fighter;
|
||||
}
|
||||
|
||||
function eliteBonusMultiplier(stackCount, bonusMultiplier, stackExponent) {
|
||||
const stackedMultiplier = Math.pow(stackCount, stackExponent);
|
||||
return 1 + bonusMultiplier * (stackedMultiplier - 1);
|
||||
}
|
||||
|
||||
export function syncFighterHud(
|
||||
fighter,
|
||||
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { FIGHTER } from "../../constants.js";
|
||||
import { getFighterType } from "./fighterStats.js";
|
||||
|
||||
export function pickUniqueFighters(fighters, count) {
|
||||
if (count > fighters.length) {
|
||||
throw new Error(`Cannot pick ${count} fighters from ${fighters.length} entries.`);
|
||||
@@ -20,6 +23,29 @@ export function pickFighters(fighters, count) {
|
||||
return picks;
|
||||
}
|
||||
|
||||
export function pickFightersForSetups(fighters, fighterSetups) {
|
||||
const eliteCount = fighterSetups.filter((fighterSetup) => fighterSetup.isElite).length;
|
||||
const normalCount = fighterSetups.length - eliteCount;
|
||||
const eligibleEliteFighters = fighters.filter(
|
||||
(fighter) => getFighterType(fighter) === FIGHTER.ELITE.TYPE,
|
||||
);
|
||||
|
||||
if (eliteCount > 0 && eligibleEliteFighters.length === 0) {
|
||||
throw new Error(`Cannot create elite fighters without ${FIGHTER.ELITE.TYPE} fighter skins.`);
|
||||
}
|
||||
|
||||
const elitePicks = pickFighters(eligibleEliteFighters, eliteCount);
|
||||
const normalPicks = pickFighters(fighters, normalCount);
|
||||
let eliteIndex = 0;
|
||||
let normalIndex = 0;
|
||||
|
||||
return fighterSetups.map((fighterSetup) => (
|
||||
fighterSetup.isElite
|
||||
? elitePicks[eliteIndex++]
|
||||
: normalPicks[normalIndex++]
|
||||
));
|
||||
}
|
||||
|
||||
function shuffleFighters(fighters) {
|
||||
const pool = [...fighters];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
||||
return fighterSetups.flatMap((fighterSetup, index) => {
|
||||
const skin = skins[index];
|
||||
const spawnMultiplier = expandSpawnMultipliers
|
||||
const spawnMultiplier = expandSpawnMultipliers && !fighterSetup.isElite
|
||||
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
||||
: 1;
|
||||
|
||||
@@ -49,6 +49,12 @@ export function clampInsideArena(value) {
|
||||
|
||||
export function syncTeamSizes(teams, fighterPlans) {
|
||||
teams.forEach((team) => {
|
||||
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
|
||||
team.size = fighterPlans
|
||||
.filter((fighterPlan) => fighterPlan.team.id === team.id)
|
||||
.reduce((sum, fighterPlan) => sum + representedFighterCount(fighterPlan), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function representedFighterCount(fighter) {
|
||||
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
|
||||
}
|
||||
|
||||
+106
-10
@@ -1,4 +1,4 @@
|
||||
import { ARENA, SPAWN, TEAM } from "../../constants.js";
|
||||
import { ARENA, FIGHTER, SPAWN, TEAM } from "../../constants.js";
|
||||
|
||||
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
||||
|
||||
@@ -38,16 +38,16 @@ export function createMatchSetup(
|
||||
);
|
||||
|
||||
const fighters = [];
|
||||
let spawnOffset = 0;
|
||||
|
||||
teams.forEach((team) => {
|
||||
for (let i = 0; i < team.size; i++) {
|
||||
const globalIndex = fighters.length;
|
||||
fighters.push({
|
||||
...spawns[globalIndex],
|
||||
name: team.label,
|
||||
team: team,
|
||||
teamIndex: i,
|
||||
});
|
||||
}
|
||||
const teamFighters = usesRandomizedEliteCompression(team)
|
||||
? createRandomizedEliteCompression(team, spawns, spawnOffset)
|
||||
: createFixedEliteRoster(team, spawns, spawnOffset);
|
||||
|
||||
fighters.push(...teamFighters);
|
||||
|
||||
spawnOffset += team.size;
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -57,6 +57,102 @@ export function createMatchSetup(
|
||||
};
|
||||
}
|
||||
|
||||
function usesRandomizedEliteCompression(team) {
|
||||
return team.size >= FIGHTER.ELITE.RANDOMIZED_COMPRESSION.MIN_TEAM_SIZE;
|
||||
}
|
||||
|
||||
function createFixedEliteRoster(team, spawns, spawnOffset) {
|
||||
const eliteCount = Math.floor(team.size / FIGHTER.ELITE.STACK_SIZE);
|
||||
const normalCount = team.size % FIGHTER.ELITE.STACK_SIZE;
|
||||
const fighters = [];
|
||||
|
||||
for (let index = 0; index < eliteCount; index += 1) {
|
||||
const teamIndex = index * FIGHTER.ELITE.STACK_SIZE;
|
||||
fighters.push(createElitePlan({
|
||||
eliteCount,
|
||||
eliteIndex: index,
|
||||
spawn: spawns[spawnOffset + teamIndex],
|
||||
stackCount: FIGHTER.ELITE.STACK_SIZE,
|
||||
team,
|
||||
teamIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
for (let index = 0; index < normalCount; index += 1) {
|
||||
const teamIndex = eliteCount * FIGHTER.ELITE.STACK_SIZE + index;
|
||||
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
|
||||
}
|
||||
|
||||
return fighters;
|
||||
}
|
||||
|
||||
function createRandomizedEliteCompression(team, spawns, spawnOffset) {
|
||||
const { ELITE_BLOCK_PROBABILITY } = FIGHTER.ELITE.RANDOMIZED_COMPRESSION;
|
||||
const stackSize = FIGHTER.ELITE.STACK_SIZE;
|
||||
const blockCount = Math.floor(team.size / stackSize);
|
||||
const normalRemainderCount = team.size % stackSize;
|
||||
const eliteBlocks = Array.from(
|
||||
{ length: blockCount },
|
||||
() => Math.random() < ELITE_BLOCK_PROBABILITY,
|
||||
);
|
||||
const eliteCount = eliteBlocks.filter(Boolean).length;
|
||||
const fighters = [];
|
||||
let eliteIndex = 0;
|
||||
|
||||
eliteBlocks.forEach((isElite, blockIndex) => {
|
||||
const blockStartIndex = blockIndex * stackSize;
|
||||
|
||||
if (isElite) {
|
||||
fighters.push(createElitePlan({
|
||||
eliteCount,
|
||||
eliteIndex,
|
||||
spawn: spawns[spawnOffset + blockStartIndex],
|
||||
stackCount: stackSize,
|
||||
team,
|
||||
teamIndex: blockStartIndex,
|
||||
}));
|
||||
eliteIndex += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < stackSize; index += 1) {
|
||||
const teamIndex = blockStartIndex + index;
|
||||
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
|
||||
}
|
||||
});
|
||||
|
||||
for (let index = 0; index < normalRemainderCount; index += 1) {
|
||||
const teamIndex = blockCount * stackSize + index;
|
||||
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
|
||||
}
|
||||
|
||||
return fighters;
|
||||
}
|
||||
|
||||
function createElitePlan({ eliteCount, eliteIndex, spawn, stackCount, team, teamIndex }) {
|
||||
return {
|
||||
...spawn,
|
||||
isElite: true,
|
||||
name: eliteCount > 1
|
||||
? `${team.label} (Elite ${eliteIndex + 1})`
|
||||
: `${team.label} (Elite)`,
|
||||
stackCount,
|
||||
team,
|
||||
teamIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function createNormalPlan(team, spawn, teamIndex) {
|
||||
return {
|
||||
...spawn,
|
||||
isElite: false,
|
||||
name: team.label,
|
||||
stackCount: 1,
|
||||
team,
|
||||
teamIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export class FighterCountLimitError extends Error {
|
||||
constructor(fighterCount) {
|
||||
super(`Requested fighter count exceeds the ${SPAWN.MAX_FIGHTER_COUNT} limit.`);
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
.score-side {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 114px);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -59,11 +59,11 @@
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1px auto;
|
||||
gap: 6px;
|
||||
width: 114px;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
padding: 8px 9px;
|
||||
padding: 8px 7px;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
@@ -113,7 +113,9 @@
|
||||
.team-score-count {
|
||||
justify-self: end;
|
||||
color: #fff2c8;
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.battle-notice {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
|
||||
--mobile-options-button-width: 54px;
|
||||
--mobile-options-gap: 8px;
|
||||
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
|
||||
--mobile-team-card-width: clamp(108px, calc((100vw - 38px) / 3), 124px);
|
||||
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
|
||||
--score-band-height: 132px;
|
||||
--score-panel-left: 10px;
|
||||
@@ -205,7 +205,7 @@
|
||||
}
|
||||
|
||||
.team-score-count {
|
||||
font-size: 0.74rem;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.team-score.is-focused {
|
||||
|
||||
@@ -24,11 +24,13 @@ export function updateScoreboard(
|
||||
|
||||
teams.forEach((team, index) => {
|
||||
const teamEl = containerLeft.children[index];
|
||||
const aliveCount = fighters.filter(
|
||||
const livingFighters = fighters.filter(
|
||||
(fighter) => fighter.team.id === team.id && !fighter.isDead,
|
||||
).length;
|
||||
);
|
||||
const eliteCount = livingFighters.filter((fighter) => fighter.isElite).length;
|
||||
const normalCount = livingFighters.length - eliteCount;
|
||||
|
||||
teamEl.disabled = aliveCount === 0;
|
||||
teamEl.disabled = livingFighters.length === 0;
|
||||
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
||||
teamEl.style.setProperty("--team-color", team.color);
|
||||
teamEl.style.backgroundColor = `${team.color}33`;
|
||||
@@ -39,7 +41,7 @@ export function updateScoreboard(
|
||||
labelEl.textContent = team.label;
|
||||
|
||||
const countEl = teamEl.querySelector(".team-score-count");
|
||||
countEl.textContent = `${aliveCount}명`;
|
||||
countEl.textContent = `E : ${eliteCount} | N : ${normalCount}`;
|
||||
|
||||
teamEl.onclick = () => {
|
||||
onTeamClick(team.id);
|
||||
|
||||
@@ -27,8 +27,8 @@ const DEATH_NOTICE_TEMPLATES = [
|
||||
const SYSTEM_TIP_TEMPLATES = [
|
||||
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
||||
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
||||
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
|
||||
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
|
||||
"팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.",
|
||||
"엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.",
|
||||
];
|
||||
|
||||
export function createDeathCounts() {
|
||||
|
||||
Reference in New Issue
Block a user