Tune special effect projectiles
This commit is contained in:
+23
-6
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user