feat: implement special effect projectiles, large-battle render budgeting, and UI optimizations
This commit is contained in:
+109
-12
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user