feat: implement elite fighter compression for large battles with randomized scaling and percentage-based damage

This commit is contained in:
2026-05-28 10:17:07 +09:00
parent 30d7be41be
commit a7eec730d2
21 changed files with 660 additions and 77 deletions
+25 -3
View File
@@ -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,
};
+4 -3
View File
@@ -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() {
+17 -8
View File
@@ -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;
+65 -9
View File
@@ -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);
}
+11 -7
View File
@@ -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;
}
+53 -7
View File
@@ -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 } = {},
+26
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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.`);
+6 -4
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+6 -4
View File
@@ -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);
+2 -2
View File
@@ -27,8 +27,8 @@ const DEATH_NOTICE_TEMPLATES = [
const SYSTEM_TIP_TEMPLATES = [
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
"팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.",
"엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.",
];
export function createDeathCounts() {