feat: implement special effect projectiles, large-battle render budgeting, and UI optimizations

This commit is contained in:
2026-05-28 16:15:52 +09:00
parent a7eec730d2
commit 9ca343c214
21 changed files with 1931 additions and 132 deletions
+109 -12
View File
@@ -12,6 +12,7 @@ export const ARENA = {
// 2. FIGHTER 도메인
export const FIGHTER = {
SCALE: 3,
ATTACK_EFFECT_SCALE_MULTIPLIER: 1,
DEPTH: 2,
DEAD_DEPTH: 1,
DEAD_DESPAWN_ALPHA: 0,
@@ -72,26 +73,29 @@ export const FIGHTER = {
},
},
ELITE: {
TYPE: "melee",
TYPE: ["melee", "magic"],
STACK_SIZE: 100,
VISUAL_SCALE_MULTIPLIER: 5,
ATTACK_EFFECT_SCALE_MULTIPLIER: 5,
HP_BONUS_RATIO: 1,
ATTACK_RANGE_MULTIPLIER: 1.5,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
ATTACK_DAMAGE_STACK_EXPONENT: 0.5,
ATTACK_DAMAGE_STACK_EXPONENT: 0.1,
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
ATTACK_SPEED_STACK_EXPONENT: 0,
ATTACK_SPEED_STACK_EXPONENT: 0.1,
MOVE_SPEED_BONUS_MULTIPLIER: 1,
MOVE_SPEED_STACK_EXPONENT: 0,
RANDOMIZED_COMPRESSION: {
MIN_TEAM_SIZE: 100,
ELITE_BLOCK_PROBABILITY: 0.6,
LARGE_BATTLE_ELITE_BLOCK_PROBABILITY: 0.8,
},
},
};
export const PERFORMANCE = {
LARGE_BATTLE_FIGHTER_THRESHOLD: 3000,
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
LARGE_BATTLE_RENDERED_FIGHTER_LIMIT: 2000,
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
FIGHTER_HUD_POOL_SIZE: 96,
@@ -111,8 +115,8 @@ export const SPAWN = {
STARTING_ZONES: "starting-zones",
},
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
MAX_FIGHTER_COUNT: 8000,
FIGHTERS_PER_STARTING_ZONE: 200,
MAX_FIGHTER_COUNT: 20000,
FIGHTERS_PER_STARTING_ZONE: 1000,
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
@@ -150,20 +154,20 @@ export const PROJECTILE = {
};
// 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = {
const WORLD_EFFECT_CONFIG = {
// Delay from match start until the first barrage.
INTERVAL: 8000,
// Delay between barrages after the first one has fired.
REPEAT_INTERVAL: 8000,
REPEAT_INTERVAL: 12000,
AREA_TILES: 40,
// How long the large dense-area warning marker remains visible.
WARNING_DURATION_MS: 2000,
IMPACT_AREA_TILES: 10,
IMPACT_COUNT_MIN: 15,
IMPACT_COUNT_MAX: 25,
IMPACT_COUNT_MIN: 10,
IMPACT_COUNT_MAX: 15,
IMPACT_STAGGER_MS: 140,
IMPACT_VISUAL_SCALE: 10,
SIZE_SCALE_VARIANCE: 0,
IMPACT_VISUAL_SCALE: 15,
SIZE_SCALE_VARIANCE: 1,
FRAMES: 7,
FRAME_RATE: 14,
FALL_DURATION: 920,
@@ -186,6 +190,99 @@ export const WORLD_EFFECT = {
},
};
export const SPECIAL_EFFECT = {
ENABLED: true,
// A special effect is picked once per battle, no earlier than the first world-effect delay.
TRIGGER_DELAY_MIN_MS: 10000,
TRIGGER_DELAY_MAX_MS: 50000,
RETRY_DELAY_MS: 2000,
CASTER: {
HURT_FRAME_INDEX: 1,
HURT_HOLD_MS: 1800,
ATTACK_LAUNCH_DELAY_MS: 360,
ATTACK_TIME_SCALE: 0.9,
POST_CAST_COOLDOWN_MS: 1200,
BALANCE_NON_MAGIC_TYPES: true,
INVULNERABLE_MS: 7000,
},
CAMERA: {
ZOOM: 3,
CENTER_ON_CASTER_AT_START: true,
ZOOM_IN_MS: 720,
HOLD_MS: 1100,
ZOOM_OUT_MS: 1300,
LERP: 0.045,
PROJECTILE_VIEW_ZOOM: 1,
PROJECTILE_ZOOM_OUT_MS: 300,
},
FOCUS_LAYER: {
ENABLED: true,
BLUR_DEPTH: 5.2,
DIM_DEPTH: 5.3,
CASTER_DEPTH: 8,
BLUR_ALPHA: 0.78,
DIM_ALPHA: 0.34,
BLUR_QUALITY: 1,
BLUR_OFFSET_X: 3,
BLUR_OFFSET_Y: 3,
BLUR_STRENGTH: 1.35,
BLUR_STEPS: 6,
FADE_IN_MS: 160,
FADE_OUT_MS: 220,
},
MELEE: {
SCALE: 15,
FRAME_WIDTH: 100,
FRAME_HEIGHT: 100,
FRAME_RATE: 12,
DEPTH: 6,
SPAWN_DISTANCE: TILE_SIZE * 1.2,
ASSETS: [
{
key: "special-melee-effect-1",
path: "assets/effects/special/melee/melee_Effect_1.png",
frames: 11,
frameSequence: [4, 5, 6, 7, 7, 7, 7, 7, 8, 9],
},
{
key: "special-melee-effect-2",
path: "assets/effects/special/melee/melee_Effect_2.png",
frames: 8,
frameSequence: [2, 3, 4, 5, 5, 5, 5, 5, 5, 5, 5],
},
{
key: "special-melee-effect-3",
path: "assets/effects/special/melee/melee_Effect_3.png",
frames: 11,
frameSequence: [4, 5, 6, 7, 8, 8, 8, 8, 8, 9, 10],
},
],
},
PROJECTILE: {
key: "special-projectile-effect-1",
path: "assets/effects/special/projectile/projectile_Effect_1.png",
frames: 12,
frameWidth: 100,
frameHeight: 100,
frameRate: 20,
scale: 16,
speed: 1150,
depth: 7,
spawnDistance: TILE_SIZE * 2.8,
startHoldMs: 360,
targetAreaTiles: 8,
travelTiles: GRID_SIZE * 1.6,
arenaEdgePadding: TILE_SIZE * 2,
hitRadius: TILE_SIZE * 2.2,
maxLifetimeMs: 5200,
},
};
export const WORLD_EFFECT = {
...WORLD_EFFECT_CONFIG,
SPECIAL: SPECIAL_EFFECT,
};
// 7. CAMERA 도메인
export const CAMERA = {
MIN_ZOOM: 1,
+216 -3
View File
@@ -4,6 +4,7 @@ import {
CAMERA,
COMBAT,
PERFORMANCE,
SPECIAL_EFFECT,
SPAWN,
UI,
} from "../../constants.js";
@@ -16,6 +17,12 @@ import {
startWorldEffects,
updateWorldEffectModifiers,
} from "../combat/worldEffects.js";
import {
clearSpecialEffects,
createSpecialEffectAnimations,
preloadSpecialEffectAssets,
startSpecialEffects,
} from "../combat/specialEffects.js";
import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.js";
import {
createFighter,
@@ -114,11 +121,19 @@ export class ArenaScene extends Phaser.Scene {
this.minimapHudCamera = null;
this.worldEffectTimer = null;
this.worldEffectZones = new Set();
this.specialEffectTimer = null;
this.specialEffectStartedAt = null;
this.specialEffectUsed = false;
this.specialEffectCastState = null;
this.specialEffectFocusState = null;
this.specialEffectFocusTween = null;
this.specialEffectFocusClearTimer = null;
}
preload() {
preloadFighterSheets(this, fighterManifest);
preloadWorldEffectAssets(this);
preloadSpecialEffectAssets(this);
}
create() {
@@ -129,6 +144,7 @@ export class ArenaScene extends Phaser.Scene {
this.startingZoneGraphics = this.add.graphics().setDepth(0.5);
createFighterAnimations(this, fighterManifest);
createWorldEffectAnimations(this);
createSpecialEffectAnimations(this);
// 미니맵 카메라 설정
this.minimapGraphics = this.add.graphics().setDepth(10);
@@ -218,6 +234,7 @@ export class ArenaScene extends Phaser.Scene {
this.setPaused(false, { silent: true });
this.clearFinalCombatEffects();
clearWorldEffects(this);
clearSpecialEffects(this);
this.presentationMode = silent;
this.resetMatchDeathStats({ silent });
this.observedCombat = [];
@@ -233,6 +250,7 @@ export class ArenaScene extends Phaser.Scene {
this.showStartingZones(matchSetup.startingZones);
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
startWorldEffects(this);
startSpecialEffects(this);
if (!silent) {
trackMatchStart();
@@ -453,6 +471,11 @@ update(time) {
return;
}
if (this.followSpecialEffectCameraFocus()) {
this.updateMinimap();
return;
}
if (this.focusSelectedFighter()) {
this.updateMinimap();
return;
@@ -533,7 +556,7 @@ update(time) {
return fighterCameraPoint(this.finalFocusTarget);
}
moveCameraToward(target) {
moveCameraToward(target, lerp = CAMERA.SPECTATOR_LERP) {
if (!target) {
return;
}
@@ -541,8 +564,197 @@ update(time) {
const targetX = Math.round(target.x);
const targetY = Math.round(target.y);
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * CAMERA.SPECTATOR_LERP;
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * lerp;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * lerp;
}
beginSpecialEffectCameraFocus(target) {
if (this.presentationMode || this.matchOver) {
return;
}
this.clearMeteorCameraFocus(null, { restoreCamera: false, immediate: true });
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
this.specialEffectFocusTween?.remove();
this.specialEffectFocusTween = null;
const previousFocus = this.specialEffectFocusState;
const focusState = {
restoreCenter: previousFocus?.restoreCenter ?? {
x: this.cameras.main.midPoint.x,
y: this.cameras.main.midPoint.y,
},
restoreZoom: previousFocus?.restoreZoom ?? this.cameras.main.zoom,
returning: false,
snapToTarget: Boolean(SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START),
target,
zoom: this.cameras.main.zoom,
};
this.specialEffectFocusState = focusState;
if (SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START) {
const focusTarget = this.resolveSpecialEffectCameraTarget(focusState);
if (focusTarget) {
this.cameras.main.centerOn(
Math.round(focusTarget.x),
Math.round(focusTarget.y),
);
}
}
this.specialEffectFocusTween = this.tweens.add({
targets: focusState,
zoom: SPECIAL_EFFECT.CAMERA.ZOOM,
duration: SPECIAL_EFFECT.CAMERA.ZOOM_IN_MS,
ease: "Cubic.Out",
onComplete: () => {
if (this.specialEffectFocusState === focusState) {
this.specialEffectFocusTween = null;
}
},
});
}
zoomOutSpecialEffectCameraFocus() {
if (!this.specialEffectFocusState || this.specialEffectFocusState.returning) {
return;
}
const focusState = this.specialEffectFocusState;
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
this.specialEffectFocusTween?.remove();
this.specialEffectFocusTween = null;
focusState.target = {
x: this.cameras.main.midPoint.x,
y: this.cameras.main.midPoint.y,
};
this.specialEffectFocusState.snapToTarget = false;
this.specialEffectFocusTween = this.tweens.add({
targets: focusState,
zoom: SPECIAL_EFFECT.CAMERA.PROJECTILE_VIEW_ZOOM,
duration: SPECIAL_EFFECT.CAMERA.PROJECTILE_ZOOM_OUT_MS,
ease: "Cubic.Out",
onComplete: () => {
if (this.specialEffectFocusState === focusState) {
this.specialEffectFocusTween = null;
}
},
});
}
followSpecialEffectCameraFocus() {
const focusState = this.specialEffectFocusState;
if (!focusState) {
return false;
}
this.setMainCameraZoom(focusState.zoom ?? SPECIAL_EFFECT.CAMERA.ZOOM);
const target = this.resolveSpecialEffectCameraTarget(focusState);
if (focusState.snapToTarget) {
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
} else {
this.moveCameraToward(target, SPECIAL_EFFECT.CAMERA.LERP);
}
return true;
}
resolveSpecialEffectCameraTarget(focusState) {
const target = focusState.target;
let focusTarget = focusState.restoreCenter;
if (target?.body?.center) {
focusTarget = target.body.center;
} else if (Number.isFinite(target?.x) && Number.isFinite(target?.y)) {
focusTarget = target;
}
return this.clampSpecialEffectCameraTarget(focusTarget);
}
clampSpecialEffectCameraTarget(target) {
if (!target) {
return null;
}
const camera = this.cameras.main;
const zoom = Math.max(CAMERA.MIN_ZOOM, Number(camera.zoom) || CAMERA.MIN_ZOOM);
const viewWidth = (Number(camera.width) || ARENA.SIZE) / zoom;
const viewHeight = (Number(camera.height) || ARENA.SIZE) / zoom;
const halfWidth = Math.min(ARENA.SIZE / 2, viewWidth / 2);
const halfHeight = Math.min(ARENA.SIZE / 2, viewHeight / 2);
return {
x: Phaser.Math.Clamp(target.x, halfWidth, ARENA.SIZE - halfWidth),
y: Phaser.Math.Clamp(target.y, halfHeight, ARENA.SIZE - halfHeight),
};
}
clearSpecialEffectCameraFocus({ restoreCamera = true, immediate = false } = {}) {
if (!this.specialEffectFocusState) {
return;
}
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
if (immediate) {
this.performClearSpecialEffectCameraFocus(restoreCamera, true);
return;
}
this.specialEffectFocusClearTimer = this.time.delayedCall(
SPECIAL_EFFECT.CAMERA.HOLD_MS,
() => this.performClearSpecialEffectCameraFocus(restoreCamera),
);
}
performClearSpecialEffectCameraFocus(restoreCamera, immediate = false) {
const focusState = this.specialEffectFocusState;
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
this.specialEffectFocusTween?.remove();
this.specialEffectFocusTween = null;
if (!focusState) {
return;
}
if (!restoreCamera || immediate || this.presentationMode || this.matchOver) {
this.specialEffectFocusState = null;
return;
}
focusState.returning = true;
focusState.target = focusState.restoreCenter;
this.specialEffectFocusTween = this.tweens.add({
targets: focusState,
zoom: focusState.restoreZoom,
duration: SPECIAL_EFFECT.CAMERA.ZOOM_OUT_MS,
ease: "Cubic.InOut",
onComplete: () => {
if (this.specialEffectFocusState !== focusState) {
return;
}
this.specialEffectFocusState = null;
this.specialEffectFocusTween = null;
this.setMainCameraZoom(focusState.restoreZoom);
this.cameras.main.centerOn(
Math.round(focusState.restoreCenter.x),
Math.round(focusState.restoreCenter.y),
);
},
});
}
beginMeteorCameraFocus(zone) {
@@ -1196,6 +1408,7 @@ update(time) {
this.matchOver = true;
this.clearFinalCombatEffects();
clearWorldEffects(this);
clearSpecialEffects(this);
clearCombatObjects(this);
this.fighters.forEach((fighter) => {
if (fighter.body) {
+85 -4
View File
@@ -19,7 +19,11 @@ import {
healEffectAnimationKey,
healEffectKey,
} from "../fighter/fighterAssets.js";
import { getFighterStats } from "../fighter/fighterStats.js";
import {
FIGHTER_TYPES,
getFighterStats,
getFighterType,
} from "../fighter/fighterStats.js";
const TARGET_SCAN_INTERVAL_MS = 180;
const TARGET_SCAN_JITTER_MS = 90;
@@ -29,7 +33,13 @@ export function prepareCombatFrame(scene) {
}
export function updateFighter(scene, fighter, time, onWinner) {
if (!fighter.active || fighter.isDead || fighter.isFrostStunned || fighter.isLocked) {
if (
scene.specialEffectPreparationPaused
|| !fighter.active
|| fighter.isDead
|| fighter.isFrostStunned
|| fighter.isLocked
) {
fighter.body?.setVelocity(0, 0);
return;
}
@@ -227,7 +237,7 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack)
if (shouldRenderCombatEffects(scene)) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3);
effect.setScale(FIGHTER.SCALE);
effect.setScale(attackEffectScaleFor(attacker));
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
@@ -251,6 +261,10 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
return;
}
if (isFighterSpecialInvulnerable(defender)) {
return;
}
if (isCritical && shouldRenderCombatEffects(scene)) {
spawnCriticalHitLabel(scene, defender);
}
@@ -274,7 +288,12 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
}
export function applyWorldEffectDamage(scene, defender, effectType) {
if (scene.matchOver || !defender?.active || defender.isDead) {
if (
scene.matchOver
|| !defender?.active
|| defender.isDead
|| isFighterSpecialInvulnerable(defender)
) {
return false;
}
@@ -297,6 +316,28 @@ export function applyWorldEffectDamage(scene, defender, effectType) {
return false;
}
export function applySpecialEffectInstantKill(scene, defender, attacker) {
if (
scene.matchOver
|| !defender?.active
|| defender.isDead
|| isFighterSpecialInvulnerable(defender)
) {
return false;
}
defender.hp = 0;
defender.body?.setVelocity(0, 0);
const winner =
attacker?.active && !attacker.isDead && attacker !== defender
? attacker
: undefined;
killFighter(defender, winner);
return true;
}
function criticalDamageFor(defender, normalDamage) {
if (!defender.isElite) {
return normalDamage * COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER;
@@ -329,6 +370,32 @@ function worldEffectDamageFor(defender, effectType) {
return Math.max(0, Math.ceil((defender.maxHp ?? 1) * percentage));
}
export function isFighterSpecialInvulnerable(fighter) {
if (!fighter?.isSpecialInvulnerable) {
return false;
}
const invulnerableUntil = Number(fighter.specialInvulnerableUntil) || 0;
if (invulnerableUntil > resolveRealtimeNow()) {
return true;
}
fighter.isSpecialInvulnerable = false;
fighter.specialInvulnerableUntil = 0;
if (fighter.specialInvulnerabilityTimer) {
globalThis.clearTimeout(fighter.specialInvulnerabilityTimer);
fighter.specialInvulnerabilityTimer = null;
}
return false;
}
function resolveRealtimeNow() {
return globalThis.performance?.now?.() ?? Date.now();
}
function spawnCriticalHitLabel(scene, defender) {
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE);
const label = scene.add
@@ -368,6 +435,20 @@ function getCombatType(fighter) {
return fighter.skin.combat?.type ?? "melee";
}
function attackEffectScaleFor(attacker) {
const baseScale =
FIGHTER.SCALE * FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER;
if (
!attacker.isElite ||
getFighterType(attacker.skin) !== FIGHTER_TYPES.MAGIC
) {
return baseScale;
}
return baseScale * FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER;
}
function createAttackProfile(attacker) {
const isCritical = Math.random() < getCriticalChance(attacker);
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -6,6 +6,7 @@ import {
import {
applyWorldEffectDamage,
disposeCombatObject,
isFighterSpecialInvulnerable,
trackCombatObject,
} from "./combat.js";
@@ -529,7 +530,12 @@ function resolveImpactDamage(scene, zone, effectType, onSurvivor) {
let deathCount = 0;
scene.fighters
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
.filter((fighter) =>
fighter.active
&& !fighter.isDead
&& !isFighterSpecialInvulnerable(fighter)
&& containsFighter(zone, fighter),
)
.forEach((fighter) => {
if (applyWorldEffectDamage(scene, fighter, effectType)) {
deathCount += 1;
+2 -24
View File
@@ -10,7 +10,6 @@ import {
} from "./fighterAssets.js";
import { getFighterStats } from "./fighterStats.js";
const NAME_LABEL_BOTTOM_GAP = 14;
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
export function createFighter(
@@ -175,11 +174,7 @@ export function syncFighterHud(
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
const healthOffset = 44 * scaleRatio;
const hitbox = fighter.body;
const nameX = hitbox.x + hitbox.width / 2;
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
hudSlot.nameLabel.setPosition(nameX, nameY);
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
@@ -232,7 +227,6 @@ function setVisibleIfChanged(gameObject, visible) {
}
function setHudSlotVisible(hudSlot, visible) {
setVisibleIfChanged(hudSlot.nameLabel, visible);
setVisibleIfChanged(hudSlot.healthBack, visible);
setVisibleIfChanged(hudSlot.healthBar, visible);
}
@@ -251,7 +245,7 @@ function acquireFighterHudSlot(fighter) {
hudSlot.fighter = fighter;
fighter._hudSlot = hudSlot;
configureHudSlot(hudSlot, fighter);
configureHudSlot(hudSlot);
return hudSlot;
}
@@ -266,18 +260,6 @@ function ensureFighterHudPool(scene) {
}
function createHudSlot(scene) {
const nameLabel = scene.add
.text(0, 0, "", {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
fontStyle: "700",
stroke: "#17180e",
strokeThickness: 4,
})
.setOrigin(0.5, 0)
.setDepth(4)
.setVisible(false);
const healthBack = scene.add
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
.setDepth(4)
@@ -292,14 +274,10 @@ function createHudSlot(scene) {
fighter: null,
healthBack,
healthBar,
nameLabel,
};
}
function configureHudSlot(hudSlot, fighter) {
hudSlot.nameLabel.setText(fighter.fighterName ?? fighter.name ?? "");
hudSlot.nameLabel.setStroke(fighter.team?.color ?? "#17180e", 4);
hudSlot.nameLabel.setDepth(4);
function configureHudSlot(hudSlot) {
hudSlot.healthBack.setDepth(4);
hudSlot.healthBar.setDepth(5);
}
+3 -3
View File
@@ -11,7 +11,7 @@ export const fighterManifest = [
walk: animation("Knight-Walk.png", 8),
attack: animation("Knight-Attack01.png", 7),
attack02: animation("Knight-Attack02.png", 10),
attack03: animation("Knight-Attack03.png", 11),
// attack03: animation("Knight-Attack03.png", 11),
block: animation("Knight-Block.png", 4),
hurt: animation("Knight-Hurt.png", 4),
death: animation("Knight-Death.png", 4),
@@ -154,7 +154,7 @@ export const fighterManifest = [
walk02: animation("Lancer-Walk02.png", 8),
attack: animation("Lancer-Attack01.png", 6),
attack02: animation("Lancer-Attack02.png", 9),
attack03: animation("Lancer-Attack03.png", 8),
// attack03: animation("Lancer-Attack03.png", 8),
hurt: animation("Lancer-Hurt.png", 4),
death: animation("Lancer-Death.png", 4),
},
@@ -190,7 +190,7 @@ export const fighterManifest = [
animations: {
idle: animation("Priest-Idle.png", 6),
walk: animation("Priest-Walk.png", 8),
attack: animation("Priest-Attack.png", 9),
attack: animation("Priest-Heal.png", 6),
heal: animation("Priest-Heal.png", 6),
hurt: animation("Priest-Hurt.png", 4),
death: animation("Priest-Death.png", 4),
+15 -8
View File
@@ -3,7 +3,9 @@ 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.`);
throw new Error(
`Cannot pick ${count} fighters from ${fighters.length} entries.`,
);
}
return shuffleFighters(fighters).slice(0, count);
@@ -24,14 +26,19 @@ export function pickFighters(fighters, count) {
}
export function pickFightersForSetups(fighters, fighterSetups) {
const eliteCount = fighterSetups.filter((fighterSetup) => fighterSetup.isElite).length;
const eliteCount = fighterSetups.filter(
(fighterSetup) => fighterSetup.isElite,
).length;
const normalCount = fighterSetups.length - eliteCount;
const eligibleEliteFighters = fighters.filter(
(fighter) => getFighterType(fighter) === FIGHTER.ELITE.TYPE,
const eligibleEliteFighters = fighters.filter((fighter) =>
FIGHTER.ELITE.TYPE.includes(getFighterType(fighter)),
);
if (eliteCount > 0 && eligibleEliteFighters.length === 0) {
throw new Error(`Cannot create elite fighters without ${FIGHTER.ELITE.TYPE} fighter skins.`);
throw new Error(
`Cannot create elite fighters without ${FIGHTER.ELITE.TYPE} fighter skins.`,
);
}
const elitePicks = pickFighters(eligibleEliteFighters, eliteCount);
@@ -39,11 +46,11 @@ export function pickFightersForSetups(fighters, fighterSetups) {
let eliteIndex = 0;
let normalIndex = 0;
return fighterSetups.map((fighterSetup) => (
return fighterSetups.map((fighterSetup) =>
fighterSetup.isElite
? elitePicks[eliteIndex++]
: normalPicks[normalIndex++]
));
: normalPicks[normalIndex++],
);
}
function shuffleFighters(fighters) {
+166 -56
View File
@@ -1,4 +1,4 @@
import { ARENA, FIGHTER, SPAWN, TEAM } from "../../constants.js";
import { ARENA, FIGHTER, PERFORMANCE, SPAWN, TEAM } from "../../constants.js";
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
@@ -37,21 +37,23 @@ export function createMatchSetup(
startingZones,
);
const fighters = [];
const teamRosters = [];
let spawnOffset = 0;
teams.forEach((team) => {
const teamFighters = usesRandomizedEliteCompression(team)
? createRandomizedEliteCompression(team, spawns, spawnOffset)
const teamRoster = usesRandomizedEliteCompression(team)
? createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters)
: createFixedEliteRoster(team, spawns, spawnOffset);
fighters.push(...teamFighters);
teamRosters.push(teamRoster);
spawnOffset += team.size;
});
enforceRenderedFighterLimit(teamRosters, totalFighters);
return {
fighters,
fighters: teamRosters.flatMap(materializeEliteRoster),
startingZones,
teams,
};
@@ -63,70 +65,178 @@ function usesRandomizedEliteCompression(team) {
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],
return {
blocks: Array.from({ length: eliteCount }, (_, index) => ({
isElite: true,
startIndex: index * FIGHTER.ELITE.STACK_SIZE,
stackCount: FIGHTER.ELITE.STACK_SIZE,
team,
teamIndex,
}));
})),
normalRemainderCount: team.size % FIGHTER.ELITE.STACK_SIZE,
remainderIsElite: false,
spawnOffset,
spawns,
team,
};
}
function createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters) {
const eliteBlockProbability = resolveEliteBlockProbability(totalFighters);
const stackSize = FIGHTER.ELITE.STACK_SIZE;
const blockCount = Math.floor(team.size / stackSize);
return {
blocks: Array.from({ length: blockCount }, (_, blockIndex) => ({
canPromote: true,
isElite: Math.random() < eliteBlockProbability,
startIndex: blockIndex * stackSize,
stackCount: stackSize,
})),
normalRemainderCount: team.size % stackSize,
remainderIsElite: false,
spawnOffset,
spawns,
team,
};
}
function enforceRenderedFighterLimit(rosters, totalFighters) {
if (totalFighters <= PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) {
return;
}
for (let index = 0; index < normalCount; index += 1) {
const teamIndex = eliteCount * FIGHTER.ELITE.STACK_SIZE + index;
const renderLimit = Math.max(
1,
Math.round(Number(PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT) || 1),
);
let overflow = renderedFighterCount(rosters) - renderLimit;
if (overflow <= 0) {
return;
}
const promotableGroups = shuffle(
rosters.flatMap((roster) =>
[
...roster.blocks
.filter((block) => block.canPromote && !block.isElite)
.map((block) => ({
promote: () => {
block.isElite = true;
},
stackCount: block.stackCount,
})),
...(roster.normalRemainderCount > 1 && !roster.remainderIsElite
? [{
promote: () => {
roster.remainderIsElite = true;
},
stackCount: roster.normalRemainderCount,
}]
: []),
],
),
);
for (const group of promotableGroups) {
if (overflow <= 0) {
break;
}
group.promote();
overflow -= group.stackCount - 1;
}
}
function renderedFighterCount(rosters) {
return rosters.reduce(
(sum, roster) =>
sum
+ (roster.remainderIsElite ? 1 : roster.normalRemainderCount)
+ roster.blocks.reduce(
(blockSum, block) => blockSum + (block.isElite ? 1 : block.stackCount),
0,
),
0,
);
}
function materializeEliteRoster(roster) {
const {
blocks,
normalRemainderCount,
remainderIsElite,
spawnOffset,
spawns,
team,
} = roster;
const eliteCount =
blocks.filter((block) => block.isElite).length + (remainderIsElite ? 1 : 0);
const fighters = [];
let eliteIndex = 0;
blocks.forEach((block) => {
if (block.isElite) {
fighters.push(createElitePlan({
eliteCount,
eliteIndex,
spawn: spawns[spawnOffset + block.startIndex],
stackCount: block.stackCount,
team,
teamIndex: block.startIndex,
}));
eliteIndex += 1;
return;
}
for (let index = 0; index < block.stackCount; index += 1) {
const teamIndex = block.startIndex + index;
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
}
});
const remainderStartIndex = blocks.length * FIGHTER.ELITE.STACK_SIZE;
if (remainderIsElite) {
fighters.push(createElitePlan({
eliteCount,
eliteIndex,
spawn: spawns[spawnOffset + remainderStartIndex],
stackCount: normalRemainderCount,
team,
teamIndex: remainderStartIndex,
}));
return fighters;
}
for (let index = 0; index < normalRemainderCount; index += 1) {
const teamIndex = remainderStartIndex + 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;
function resolveEliteBlockProbability(totalFighters) {
const {
ELITE_BLOCK_PROBABILITY,
LARGE_BATTLE_ELITE_BLOCK_PROBABILITY,
} = FIGHTER.ELITE.RANDOMIZED_COMPRESSION;
eliteBlocks.forEach((isElite, blockIndex) => {
const blockStartIndex = blockIndex * stackSize;
const baseProbability = clampProbability(ELITE_BLOCK_PROBABILITY);
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));
if (totalFighters <= PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) {
return baseProbability;
}
return fighters;
return Math.max(
baseProbability,
clampProbability(LARGE_BATTLE_ELITE_BLOCK_PROBABILITY),
);
}
function clampProbability(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function createElitePlan({ eliteCount, eliteIndex, spawn, stackCount, team, teamIndex }) {