Tune special effect projectiles

This commit is contained in:
Horoli 2026-06-01 21:59:41 +09:00
parent f650592676
commit 7814ed3951
4 changed files with 347 additions and 54 deletions

View File

@ -1,3 +1,31 @@
# Update: Special Projectile Trail
- Special projectile movement can leave short-lived visual afterimages controlled by `SPECIAL_EFFECT.PROJECTILE.TRAIL`.
- Trail sprites copy the projectile's current texture frame, scale, rotation, and flip state, then fade out without affecting hit detection or damage.
- Trail density and cost are bounded by `TRAIL.INTERVAL_MS` and `TRAIL.LIFETIME_MS`.
# Update: Split Special Projectile Visual Configs
- Special projectile visual asset settings are split by caster type: melee visuals live under `SPECIAL_EFFECT.MELEE`, and ranged visuals live under `SPECIAL_EFFECT.RANGE`.
- `SPECIAL_EFFECT.PROJECTILE` now owns shared movement, hit-detection, and trail tuning only, such as acceleration, travel duration, hold time, target area, arena clamp, hit radius, lifetime, and afterimages.
# Update: One-Shot Accelerating Special Projectile
- Melee special sprites now use `SPECIAL_EFFECT.MELEE.REPEAT = 0`, so the configured sprite sheet plays once instead of looping while the projectile travels.
- `special-melee-effect-1` has its `frameSequence` commented out for now, restoring the natural sprite-sheet order. A commented example remains next to the asset for quick tuning later.
- Special projectile movement now uses a tween instead of constant `physics.moveTo`. `SPECIAL_EFFECT.PROJECTILE.startHoldMs` controls the stationary pre-launch tell, `travelDurationMs` controls launch speed when set, and `movementEase` controls the acceleration curve; `speed` remains the fallback if `travelDurationMs` is unset.
# Update: Special Effect Frame Sequence Refresh
- Special effect animations now compare the existing Phaser animation against the current configured frames, repeat count, and frame rate. If the config changed, the old global animation key is removed and recreated so `frameSequence` edits take effect without stale animation data.
- `frameSequence` remains 1-based for sprite-sheet inspection, then converts through `generateFrameNumbers(..., { frames })`, preserving repeated frames when a special asset enables a custom sequence.
# Update: Special Effect Frame Rate And Render Budget
- Special effect sprite animations now multiply their configured `frameRate` by `SPECIAL_EFFECT.FRAME_RATE_MULTIPLIER`. This changes only visual frame playback; caster hold time, launch delay, projectile speed, travel distance, and timers keep their existing progress speed.
- The special focus blur snapshot is skipped when the living fighter count is above `SPECIAL_EFFECT.FOCUS_LAYER.BLUR_MAX_FIGHTERS`. The dim layer and raised caster focus still render, avoiding the expensive full-arena render-texture blur during larger battles.
- Special projectile hit checks now use the per-frame combat spatial index to inspect only fighters near the projectile segment when the index is available, while preserving the full-array fallback.
# Update: Elite Kill Splash
- Elite fighters now trigger a kill splash when they directly kill an enemy. The splash is centered on the killed fighter's body position.
@ -22,9 +50,9 @@
- The caster is emphasized with a temporary focus stack: a blurred render-texture snapshot of the battlefield, a dim layer, then the caster and special launch/projectile effects above that layer. `SPECIAL_EFFECT.FOCUS_LAYER` controls depths, blur, alpha, and fades.
- `SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START` makes the camera center on the caster location immediately when the special cast begins, before the slower zoom/focus motion continues.
- When the special projectile starts moving, the special camera stops zooming in and zooms out in place through `SPECIAL_EFFECT.CAMERA.PROJECTILE_VIEW_ZOOM` and `PROJECTILE_ZOOM_OUT_MS`; it does not follow the projectile.
- Special melee sprites use explicit 1-based `frameSequence` arrays, holding the largest frames longer than normal linear playback. Because melee sprites are moving projectiles, their animations loop while traveling. The projectile uses `startHoldMs` to remain visible near the caster before traveling.
- Special melee sprites can use explicit 1-based `frameSequence` arrays, but `special-melee-effect-1` currently leaves the sequence disabled to play the sheet once in natural order. The projectile uses `startHoldMs` to remain visible near the caster before traveling.
- At target-selection time, the projectile locks onto the densest enemy tile area using represented `stackCount` population. `SPECIAL_EFFECT.PROJECTILE.targetAreaTiles` controls that scan footprint.
- The moving special projectile visual is selected by caster type: melee casters fire one random sprite from `SPECIAL_EFFECT.MELEE.ASSETS`, while ranged casters fire `public/assets/effects/special/projectile/projectile_Effect_1.png`. Projectile movement uses an Arcade Physics sprite and `physics.moveTo`, matching the normal ranged projectile position-update path. Projectile travel is clamped by `SPECIAL_EFFECT.PROJECTILE.arenaEdgePadding`, and any living fighter intersecting the projectile path is killed instantly, with kill/death records flowing through the normal combat cleanup path.
- The moving special projectile visual is selected by caster type: melee casters fire one random sprite from `SPECIAL_EFFECT.MELEE.ASSETS`, while ranged casters use `SPECIAL_EFFECT.RANGE`. Projectile movement uses a tweened Arcade Physics sprite so acceleration can be tuned while path hit checks still run per update. Projectile travel is clamped by `SPECIAL_EFFECT.PROJECTILE.arenaEdgePadding`, and any living fighter intersecting the projectile path is killed instantly, with kill/death records flowing through the normal combat cleanup path.
- Special preparation pauses existing combat objects as well as fighters: active battle tweens, world-effect fall tweens, combat-object animations, physics velocities, scene time, and Arcade Physics are restored only after the realtime Hurt-frame hold finishes.
# Update: Large-Battle Render Budget

View File

@ -1,3 +1,31 @@
# Update: Special Projectile Trail
- `SPECIAL_EFFECT.PROJECTILE.TRAIL` controls optional afterimages for the moving special projectile. Each trail copy uses the projectile's current texture frame, scale, rotation, and flip state.
- Trail objects are visual-only combat objects: they fade out and self-dispose, but they do not participate in hit detection.
- `TRAIL.INTERVAL_MS` and `TRAIL.LIFETIME_MS` bound how many afterimages can exist at once.
# Update: Split Special Projectile Visual Configs
- Special projectile visual asset settings are separated by caster type. Melee visual sheets are configured under `SPECIAL_EFFECT.MELEE`; ranged visual sheets are configured under `SPECIAL_EFFECT.RANGE`.
- `SPECIAL_EFFECT.PROJECTILE` now carries shared projectile behavior only: hold time, acceleration/ease, fallback speed, target density area, travel clamp, hit radius, max lifetime, and optional trail visuals.
# Update: One-Shot Accelerating Special Projectile
- `SPECIAL_EFFECT.MELEE.REPEAT = 0` makes melee special sprites play once. `special-melee-effect-1` currently has `frameSequence` commented out, so it uses the sprite sheet's natural frame order.
- Special projectile movement now uses a tween instead of constant `physics.moveTo`. `SPECIAL_EFFECT.PROJECTILE.startHoldMs` keeps the effect stationary for the pre-launch tell, `travelDurationMs` controls the launch duration when set, and `movementEase` controls the acceleration curve. If `travelDurationMs` is unset, movement falls back to `speed`.
- Projectile hit checks still run from the scene UPDATE event while the tween moves the sprite, so instant-kill path detection remains active during acceleration.
# Update: Special Effect Frame Sequence Refresh
- `createSpecialAnimation()` now rebuilds a special animation when the existing Phaser global animation no longer matches the configured frames, repeat count, or frame rate. This prevents stale animation keys from hiding `frameSequence` edits.
- `frameSequence` values stay 1-based in config and are converted with `generateFrameNumbers(..., { frames })`, so repeated frames are preserved when a special asset enables a custom sequence.
# Update: Special Effect Frame Rate And Render Budget
- `SPECIAL_EFFECT.FRAME_RATE_MULTIPLIER` multiplies only special-effect animation frame rates. Caster sparkle, melee special sprites, and ranged special projectile frames can play faster or slower without changing caster hold time, launch delay, projectile movement speed, travel distance, or cleanup timers.
- `SPECIAL_EFFECT.FOCUS_LAYER.BLUR_MAX_FIGHTERS` caps when `specialEffects.js` creates the full-arena blurred render-texture snapshot. Above that living-fighter count, the special focus keeps the dim layer and raised caster but skips the expensive blur pass.
- Special projectile hit checks prefer `scene.combatTargetIndex` and scan only spatial cells around the projectile segment, falling back to `scene.fighters` when no index exists.
# Update: Special Effect Projectile
- `specialEffects.js` owns the one-shot special effect flow: asset preload/animation creation, random live-match scheduling, underdog caster selection, caster pose lock, launch visuals, projectile path checks, and cleanup.
@ -8,8 +36,8 @@
- The Hurt-frame preparation also spawns a caster sparkle overlay from `public/assets/effects/special/effect.png`. Its animation uses the 1-based frame sequence `[2, 3, 4]` only, positions near the caster's eyes through `SPECIAL_EFFECT.CASTER_SPARKLE`, and is disposed before the attack animation starts.
- Caster emphasis uses `SPECIAL_EFFECT.FOCUS_LAYER`: `specialEffects.js` snapshots the current battlefield into a render texture, applies Phaser Blur FX when available, adds a dim layer, and raises the caster above both layers until cleanup.
- The special camera does not follow the projectile. When projectile movement begins, `zoomOutSpecialEffectCameraFocus()` zooms out in place using `SPECIAL_EFFECT.CAMERA.PROJECTILE_VIEW_ZOOM` and `PROJECTILE_ZOOM_OUT_MS` so the projectile remains readable without dragging the camera off the arena.
- Melee special projectile effects can define 1-based `frameSequence` arrays. `specialEffects.js` converts them to Phaser frames so the largest frames can be repeated for readability, and melee projectile animations loop while traveling. The projectile's `startHoldMs` keeps it visible at the caster before travel begins.
- At target-selection time, the special projectile scans living enemies with a summed-area table and locks onto the `SPECIAL_EFFECT.PROJECTILE.targetAreaTiles` square containing the highest represented `stackCount` population. The moving projectile visual is type-based: melee casters fire one random `SPECIAL_EFFECT.MELEE.ASSETS` sprite, while ranged casters fire the configured projectile sprite. Movement uses an Arcade Physics sprite and `physics.moveTo`, matching normal ranged projectile position updates, and projectile travel is cut to the arena bounds using `arenaEdgePadding`.
- Melee special projectile effects can define 1-based `frameSequence` arrays. `specialEffects.js` converts them to Phaser frames so specific frames can be repeated for readability. The projectile's `startHoldMs` keeps it visible at the caster before travel begins.
- At target-selection time, the special projectile scans living enemies with a summed-area table and locks onto the `SPECIAL_EFFECT.PROJECTILE.targetAreaTiles` square containing the highest represented `stackCount` population. The moving projectile visual is type-based: melee casters fire one random `SPECIAL_EFFECT.MELEE.ASSETS` sprite, while ranged casters use `SPECIAL_EFFECT.RANGE`. Movement uses a tweened Arcade Physics sprite, matching normal ranged projectile path-update checks while allowing acceleration, and projectile travel is cut to the arena bounds using `arenaEdgePadding`.
- The special projectile calls `applySpecialEffectInstantKill()` from `combat.js`, so instant kills still use the normal death animation, death-stat recording, kill log attribution when there is a surviving caster, split-on-death behavior, scoreboard refresh, and match-finish checks.
# Update: Elite Magic Attack Effect Scale

View File

@ -202,6 +202,7 @@ const WORLD_EFFECT_CONFIG = {
export const SPECIAL_EFFECT = {
ENABLED: true,
FRAME_RATE_MULTIPLIER: 1.5,
// 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: 11000,
@ -255,6 +256,7 @@ export const SPECIAL_EFFECT = {
BLUR_OFFSET_Y: 3,
BLUR_STRENGTH: 1.35,
BLUR_STEPS: 6,
BLUR_MAX_FIGHTERS: 800,
FADE_IN_MS: 160,
FADE_OUT_MS: 220,
},
@ -263,6 +265,7 @@ export const SPECIAL_EFFECT = {
FRAME_WIDTH: 100,
FRAME_HEIGHT: 100,
FRAME_RATE: 12,
REPEAT: -1,
DEPTH: 6,
SPAWN_DISTANCE: TILE_SIZE * 1.2,
ASSETS: [
@ -270,39 +273,53 @@ export const SPECIAL_EFFECT = {
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],
// frameSequence: [10, 9, 8],
},
{
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],
// 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],
// frameSequence: [4, 5, 6, 7, 8, 8, 8, 8, 8, 9, 10],
},
],
},
PROJECTILE: {
RANGE: {
key: "special-projectile-effect-1",
path: "assets/effects/special/projectile/projectile_Effect_1.png",
frames: 12,
frameWidth: 100,
frameHeight: 100,
frameRate: 20,
repeat: -1,
scale: 16,
speed: 1150,
depth: 7,
spawnDistance: TILE_SIZE * 2.8,
startHoldMs: 360,
},
PROJECTILE: {
speed: 1150,
travelDurationMs: 620,
movementEase: "Cubic.In",
startHoldMs: 380,
targetAreaTiles: 8,
travelTiles: GRID_SIZE * 1.6,
arenaEdgePadding: TILE_SIZE * 2,
hitRadius: TILE_SIZE * 2.2,
maxLifetimeMs: 5200,
TRAIL: {
ENABLED: true,
INTERVAL_MS: 36,
LIFETIME_MS: 280,
ALPHA: 0.34,
SCALE_MULTIPLIER: 0.96,
DEPTH_OFFSET: -0.1,
FADE_EASE: "Cubic.Out",
},
},
};

View File

@ -33,11 +33,11 @@ export function preloadSpecialEffectAssets(scene) {
});
scene.load.spritesheet(
SPECIAL_EFFECT.PROJECTILE.key,
SPECIAL_EFFECT.PROJECTILE.path,
SPECIAL_EFFECT.RANGE.key,
SPECIAL_EFFECT.RANGE.path,
{
frameWidth: SPECIAL_EFFECT.PROJECTILE.frameWidth,
frameHeight: SPECIAL_EFFECT.PROJECTILE.frameHeight,
frameWidth: SPECIAL_EFFECT.RANGE.frameWidth,
frameHeight: SPECIAL_EFFECT.RANGE.frameHeight,
},
);
}
@ -59,15 +59,16 @@ export function createSpecialEffectAnimations(scene) {
frameSequence: asset.frameSequence,
frames: asset.frames,
key: asset.key,
repeat: -1,
repeat: SPECIAL_EFFECT.MELEE.REPEAT ?? 0,
});
});
createSpecialAnimation(scene, {
frameRate: SPECIAL_EFFECT.PROJECTILE.frameRate,
frames: SPECIAL_EFFECT.PROJECTILE.frames,
key: SPECIAL_EFFECT.PROJECTILE.key,
repeat: -1,
frameRate: SPECIAL_EFFECT.RANGE.frameRate,
frameSequence: SPECIAL_EFFECT.RANGE.frameSequence,
frames: SPECIAL_EFFECT.RANGE.frames,
key: SPECIAL_EFFECT.RANGE.key,
repeat: SPECIAL_EFFECT.RANGE.repeat ?? -1,
});
}
@ -259,14 +260,9 @@ function spawnSpecialProjectile(scene, state, caster, direction) {
end.x,
end.y,
);
const duration = Math.max(
1,
Math.min(
projectileConfig.maxLifetimeMs,
Math.round(
(actualTravelDistance / Math.max(1, projectileConfig.speed)) * 1000,
),
),
const duration = resolveProjectileTravelDuration(
actualTravelDistance,
projectileConfig,
);
const projectile = scene.physics.add
.sprite(start.x, start.y, projectileVisual.key, 0)
@ -274,7 +270,8 @@ function spawnSpecialProjectile(scene, state, caster, direction) {
.setScale(projectileVisual.scale)
.setRotation(Math.atan2(direction.y, direction.x))
.setAlpha(0.98);
let movementTimer = null;
let movementTween = null;
let nextTrailAt = 0;
projectile.body?.setAllowGravity?.(false);
projectile.body?.setVelocity(0, 0);
@ -326,10 +323,9 @@ function spawnSpecialProjectile(scene, state, caster, direction) {
projectile.cleanup = () => {
scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectileHits);
if (movementTimer) {
state.timers.delete(movementTimer);
movementTimer.remove(false);
movementTimer = null;
if (movementTween) {
movementTween.remove();
movementTween = null;
}
if (state.projectile === projectile) {
@ -350,18 +346,110 @@ function spawnSpecialProjectile(scene, state, caster, direction) {
}
scene.zoomOutSpecialEffectCameraFocus?.();
scene.physics.moveTo(projectile, end.x, end.y, projectileConfig.speed);
movementTween = scene.tweens.add({
targets: projectile,
x: end.x,
y: end.y,
duration,
ease: projectileConfig.movementEase ?? "Linear",
onUpdate: () => {
projectile.body?.updateFromGameObject?.();
movementTimer = addStateTimer(scene, state, duration, finishProjectile);
if (shouldSpawnProjectileTrail(scene, nextTrailAt)) {
spawnProjectileTrail(scene, projectile, projectileVisual);
nextTrailAt =
scene.time.now + resolveProjectileTrailInterval(projectileConfig);
}
},
onComplete: () => {
movementTween = null;
finishProjectile();
},
});
});
}
function shouldSpawnProjectileTrail(scene, nextTrailAt) {
const trailConfig = SPECIAL_EFFECT.PROJECTILE.TRAIL;
return Boolean(
trailConfig?.ENABLED &&
!scene.matchPaused &&
scene.time.now >= nextTrailAt,
);
}
function spawnProjectileTrail(scene, projectile, projectileVisual) {
if (!projectile?.active) {
return;
}
const trailConfig = SPECIAL_EFFECT.PROJECTILE.TRAIL;
const frameName = projectile.frame?.name ?? 0;
const textureKey = projectile.texture?.key ?? projectileVisual.key;
const alpha = Phaser.Math.Clamp(Number(trailConfig.ALPHA) || 0, 0, 1);
const lifetime = Math.max(1, Number(trailConfig.LIFETIME_MS) || 1);
const scaleMultiplier = Math.max(
0.01,
Number(trailConfig.SCALE_MULTIPLIER) || 1,
);
const trail = scene.add
.image(projectile.x, projectile.y, textureKey, frameName)
.setDepth(projectile.depth + (Number(trailConfig.DEPTH_OFFSET) || 0))
.setScale(projectile.scaleX * scaleMultiplier, projectile.scaleY * scaleMultiplier)
.setRotation(projectile.rotation)
.setAlpha(alpha)
.setFlipX(projectile.flipX)
.setFlipY(projectile.flipY);
trail.cleanup = () => {
scene.tweens.killTweensOf(trail);
};
trackCombatObject(scene, trail);
scene.tweens.add({
targets: trail,
alpha: 0,
scaleX: trail.scaleX * 0.9,
scaleY: trail.scaleY * 0.9,
duration: lifetime,
ease: trailConfig.FADE_EASE ?? "Cubic.Out",
onComplete: () => disposeCombatObject(scene, trail),
});
}
function resolveProjectileTrailInterval(projectileConfig) {
const interval = Number(projectileConfig.TRAIL?.INTERVAL_MS);
return Math.max(1, Number.isFinite(interval) ? interval : 36);
}
function resolveProjectileTravelDuration(distance, config) {
const explicitDuration = Number(config.travelDurationMs);
const maxLifetime = Math.max(
1,
Number(config.maxLifetimeMs) || Number.POSITIVE_INFINITY,
);
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
return Math.round(Math.min(maxLifetime, Math.max(1, explicitDuration)));
}
return Math.max(
1,
Math.min(
maxLifetime,
Math.round((distance / Math.max(1, config.speed)) * 1000),
),
);
}
function resolveSpecialProjectileVisual(caster) {
if (getFighterType(caster.skin) === FIGHTER_TYPES.MELEE) {
const asset = randomEntry(SPECIAL_EFFECT.MELEE.ASSETS);
return {
depth: SPECIAL_EFFECT.PROJECTILE.depth,
depth: SPECIAL_EFFECT.MELEE.DEPTH,
key: asset.key,
scale: SPECIAL_EFFECT.MELEE.SCALE,
spawnDistance: SPECIAL_EFFECT.MELEE.SPAWN_DISTANCE,
@ -369,10 +457,10 @@ function resolveSpecialProjectileVisual(caster) {
}
return {
depth: SPECIAL_EFFECT.PROJECTILE.depth,
key: SPECIAL_EFFECT.PROJECTILE.key,
scale: SPECIAL_EFFECT.PROJECTILE.scale,
spawnDistance: SPECIAL_EFFECT.PROJECTILE.spawnDistance,
depth: SPECIAL_EFFECT.RANGE.depth,
key: SPECIAL_EFFECT.RANGE.key,
scale: SPECIAL_EFFECT.RANGE.scale,
spawnDistance: SPECIAL_EFFECT.RANGE.spawnDistance,
};
}
@ -426,7 +514,7 @@ function resolveProjectileHits(scene, projectile, state, attacker) {
};
let killedCount = 0;
[...scene.fighters].forEach((fighter) => {
forEachProjectileHitCandidate(scene, segmentStart, segmentEnd, (fighter) => {
if (
!isLivingFighter(fighter) ||
fighter === attacker ||
@ -449,6 +537,79 @@ function resolveProjectileHits(scene, projectile, state, attacker) {
}
}
function forEachProjectileHitCandidate(scene, segmentStart, segmentEnd, callback) {
const targetIndex = scene.combatTargetIndex;
if (!targetIndex) {
scene.fighters?.forEach(callback);
return;
}
const bounds = projectileCandidateBounds(segmentStart, segmentEnd);
const minCellX = clampSpatialCell(bounds.left, targetIndex.cellSize, targetIndex.maxCellX);
const maxCellX = clampSpatialCell(bounds.right, targetIndex.cellSize, targetIndex.maxCellX);
const minCellY = clampSpatialCell(bounds.top, targetIndex.cellSize, targetIndex.maxCellY);
const maxCellY = clampSpatialCell(bounds.bottom, targetIndex.cellSize, targetIndex.maxCellY);
const seen = new Set();
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
const cell = targetIndex.cells.get(spatialCellKey(cellX, cellY));
if (!cell) {
continue;
}
cell.forEach((fighter) => {
if (seen.has(fighter)) {
return;
}
seen.add(fighter);
callback(fighter);
});
}
}
}
function projectileCandidateBounds(segmentStart, segmentEnd) {
const padding = projectileCandidatePadding();
return {
bottom: Math.max(segmentStart.y, segmentEnd.y) + padding,
left: Math.min(segmentStart.x, segmentEnd.x) - padding,
right: Math.max(segmentStart.x, segmentEnd.x) + padding,
top: Math.min(segmentStart.y, segmentEnd.y) - padding,
};
}
function projectileCandidatePadding() {
const eliteScale = Math.max(
1,
Number(FIGHTER.ELITE?.VISUAL_SCALE_MULTIPLIER) || 1,
);
const maximumBodyRadius =
Math.max(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT) *
FIGHTER.SCALE *
eliteScale *
0.5;
const maximumVisualRadius =
FIGHTER.FRAME_WIDTH * FIGHTER.SCALE * eliteScale * 0.14;
return (
SPECIAL_EFFECT.PROJECTILE.hitRadius +
Math.max(8, maximumBodyRadius, maximumVisualRadius)
);
}
function clampSpatialCell(value, cellSize, maxCell) {
return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize)));
}
function spatialCellKey(cellX, cellY) {
return `${cellX}:${cellY}`;
}
function projectileReachedEnd(projectile, start, end, travelDistance) {
if (travelDistance <= 0) {
return true;
@ -752,7 +913,9 @@ function createSpecialFocusLayer(scene, state, caster) {
const config = SPECIAL_EFFECT.FOCUS_LAYER;
const objects = [];
const previousCasterDepth = caster.depth;
const blurLayer = createBlurredBattlefieldLayer(scene, config);
const blurLayer = shouldCreateSpecialFocusBlur(scene, config)
? createBlurredBattlefieldLayer(scene, config)
: null;
if (blurLayer) {
objects.push(blurLayer);
@ -794,6 +957,24 @@ function createSpecialFocusLayer(scene, state, caster) {
});
}
function shouldCreateSpecialFocusBlur(scene, config) {
const maximumFighters = Number(config.BLUR_MAX_FIGHTERS);
if (!Number.isFinite(maximumFighters) || maximumFighters < 0) {
return true;
}
return resolveLivingFighterCount(scene) <= maximumFighters;
}
function resolveLivingFighterCount(scene) {
if (Number.isFinite(scene.combatTargetIndex?.livingCount)) {
return scene.combatTargetIndex.livingCount;
}
return (scene.fighters ?? []).filter(isLivingFighter).length;
}
function createBlurredBattlefieldLayer(scene, config) {
if (!canCreateArenaRenderTexture(scene)) {
return null;
@ -1271,36 +1452,75 @@ function createSpecialAnimation(
{ frameRate, frameSequence, frames, key, repeat },
) {
const animationKey = specialAnimationKey(key);
if (scene.anims.exists(animationKey)) {
return;
}
scene.anims.create({
const animationConfig = {
key: animationKey,
frames: resolveSpecialAnimationFrames(scene, {
frameSequence,
frames,
key,
}),
frameRate,
frameRate: resolveSpecialFrameRate(frameRate),
repeat,
};
const existingAnimation = scene.anims.get(animationKey);
if (specialAnimationMatches(existingAnimation, animationConfig)) {
return;
}
if (existingAnimation) {
scene.anims.remove(animationKey);
}
scene.anims.create(animationConfig);
}
function specialAnimationMatches(animation, config) {
if (
!animation ||
Math.abs((Number(animation.frameRate) || 0) - config.frameRate) > 0.001 ||
animation.repeat !== config.repeat ||
animation.frames.length !== config.frames.length
) {
return false;
}
return animation.frames.every((frame, index) => {
const configFrame = config.frames[index];
return (
frame.textureKey === configFrame.key &&
frame.textureFrame === configFrame.frame
);
});
}
function resolveSpecialFrameRate(frameRate) {
const baseFrameRate = Math.max(1, Number(frameRate) || 1);
const multiplier = Math.max(
0.01,
Number(SPECIAL_EFFECT.FRAME_RATE_MULTIPLIER) || 1,
);
return baseFrameRate * multiplier;
}
function resolveSpecialAnimationFrames(scene, { frameSequence, frames, key }) {
const frameCount = Math.max(1, Math.round(Number(frames) || 1));
if (!Array.isArray(frameSequence) || frameSequence.length === 0) {
return scene.anims.generateFrameNumbers(key, {
start: 0,
end: frames - 1,
end: frameCount - 1,
});
}
return frameSequence.map((frameNumber) => ({
key,
// Config uses 1-based frame numbers so visual tuning matches sprite-sheet inspection.
frame: Phaser.Math.Clamp(Math.round(frameNumber) - 1, 0, frames - 1),
}));
return scene.anims.generateFrameNumbers(key, {
frames: frameSequence.map((frameNumber) =>
// Config uses 1-based frame numbers so visual tuning matches sprite-sheet inspection.
Phaser.Math.Clamp(Math.round(frameNumber) - 1, 0, frameCount - 1),
),
});
}
function specialAnimationKey(key) {