Tune special effect projectiles

This commit is contained in:
2026-06-01 21:59:41 +09:00
parent f650592676
commit 7814ed3951
4 changed files with 347 additions and 54 deletions
+23 -6
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",
},
},
};
+264 -44
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) {