Compare commits
2 Commits
3b1a883787
...
f650592676
| Author | SHA1 | Date |
|---|---|---|
|
|
f650592676 | |
|
|
3b7fa17d06 |
|
|
@ -4,3 +4,4 @@ dist/
|
|||
config.json
|
||||
package-lock.json
|
||||
*.log
|
||||
.omo
|
||||
1
agent.md
1
agent.md
|
|
@ -18,6 +18,7 @@
|
|||
- Eligible casters are living, non-elite, non-magic fighters from teams that are not currently tied for first by represented living count. `SPECIAL_EFFECT.CASTER.BALANCE_NON_MAGIC_TYPES` first balances between available non-magic caster types, then picks a fighter inside that type, so ranged casters are not drowned out by the larger melee roster. The caster holds Hurt frame index `1` long enough for the zoom/focus layer to read, then the attack animation launches a giant projectile.
|
||||
- The caster receives realtime special invulnerability for `SPECIAL_EFFECT.CASTER.INVULNERABLE_MS`. Normal attacks, world-effect damage/frost survivor effects, and special instant kills all skip fighters whose invulnerability window is still active.
|
||||
- During that Hurt-frame preparation hold, combat is frozen by pausing fighter AI, Arcade Physics, and the scene clock, so combat/world timers do not advance. A realtime cinematic timer releases the pause when the attack motion begins.
|
||||
- While the caster holds the Hurt frame, `SPECIAL_EFFECT.CASTER_SPARKLE` plays only frames 2, 3, and 4 from `public/assets/effects/special/effect.png` above the caster's eye area, then removes the sparkle before the attack motion begins.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
- Casters must be living, non-elite, non-magic fighters from teams that are not currently tied for first by represented living count. When `SPECIAL_EFFECT.CASTER.BALANCE_NON_MAGIC_TYPES` is enabled, caster selection first picks among available non-magic types and then picks a fighter from that type, preventing the larger melee roster from overwhelming ranged special casts. If no caster exists at the chosen time, the timer retries within the configured window instead of firing multiple times.
|
||||
- Special casters receive realtime invulnerability for `SPECIAL_EFFECT.CASTER.INVULNERABLE_MS`. `combat.js` checks that window in normal attacks, world-effect damage, and special instant kills, while `worldEffects.js` also skips frost survivor effects for invulnerable fighters.
|
||||
- While the caster holds the Hurt frame, `specialEffects.js` pauses fighter AI, Arcade Physics, the scene clock, existing combat-object physics velocity, combat-object animations, and combat-object tweens. Existing combat/world delayed calls and already-falling meteor/frost tweens stop advancing until the realtime preparation hold releases into the attack animation.
|
||||
- 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.
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -215,6 +215,24 @@ export const SPECIAL_EFFECT = {
|
|||
BALANCE_NON_MAGIC_TYPES: false,
|
||||
INVULNERABLE_MS: 4500,
|
||||
},
|
||||
CASTER_SPARKLE: {
|
||||
ENABLED: true,
|
||||
key: "special-caster-eye-sparkle",
|
||||
path: "assets/effects/special/effect.png",
|
||||
frames: 12,
|
||||
frameWidth: 100,
|
||||
frameHeight: 100,
|
||||
frameRate: 12,
|
||||
frameSequence: [2, 3, 4],
|
||||
repeat: -1,
|
||||
scaleMultiplier: 1,
|
||||
anchorX: 50,
|
||||
anchorY: 40,
|
||||
effectAnchorX: 54,
|
||||
effectAnchorY: 50,
|
||||
depthOffset: 0.25,
|
||||
alpha: 1,
|
||||
},
|
||||
CAMERA: {
|
||||
ZOOM: 3,
|
||||
CENTER_ON_CASTER_AT_START: true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Phaser from "phaser";
|
||||
import { ARENA, SPECIAL_EFFECT } from "../../constants.js";
|
||||
import { ARENA, FIGHTER, SPECIAL_EFFECT } from "../../constants.js";
|
||||
import {
|
||||
applySpecialEffectInstantKill,
|
||||
disposeCombatObject,
|
||||
|
|
@ -14,6 +14,17 @@ import { FIGHTER_TYPES, getFighterType } from "../fighter/fighterStats.js";
|
|||
const SPECIAL_ANIMATION_SUFFIX = "anim";
|
||||
|
||||
export function preloadSpecialEffectAssets(scene) {
|
||||
if (SPECIAL_EFFECT.CASTER_SPARKLE.ENABLED) {
|
||||
scene.load.spritesheet(
|
||||
SPECIAL_EFFECT.CASTER_SPARKLE.key,
|
||||
SPECIAL_EFFECT.CASTER_SPARKLE.path,
|
||||
{
|
||||
frameWidth: SPECIAL_EFFECT.CASTER_SPARKLE.frameWidth,
|
||||
frameHeight: SPECIAL_EFFECT.CASTER_SPARKLE.frameHeight,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SPECIAL_EFFECT.MELEE.ASSETS.forEach((asset) => {
|
||||
scene.load.spritesheet(asset.key, asset.path, {
|
||||
frameWidth: SPECIAL_EFFECT.MELEE.FRAME_WIDTH,
|
||||
|
|
@ -32,6 +43,16 @@ export function preloadSpecialEffectAssets(scene) {
|
|||
}
|
||||
|
||||
export function createSpecialEffectAnimations(scene) {
|
||||
if (SPECIAL_EFFECT.CASTER_SPARKLE.ENABLED) {
|
||||
createSpecialAnimation(scene, {
|
||||
frameRate: SPECIAL_EFFECT.CASTER_SPARKLE.frameRate,
|
||||
frameSequence: SPECIAL_EFFECT.CASTER_SPARKLE.frameSequence,
|
||||
frames: SPECIAL_EFFECT.CASTER_SPARKLE.frames,
|
||||
key: SPECIAL_EFFECT.CASTER_SPARKLE.key,
|
||||
repeat: SPECIAL_EFFECT.CASTER_SPARKLE.repeat,
|
||||
});
|
||||
}
|
||||
|
||||
SPECIAL_EFFECT.MELEE.ASSETS.forEach((asset) => {
|
||||
createSpecialAnimation(scene, {
|
||||
frameRate: SPECIAL_EFFECT.MELEE.FRAME_RATE,
|
||||
|
|
@ -136,10 +157,12 @@ function beginSpecialCast(scene, caster, target, matchId) {
|
|||
pauseSpecialPreparationCombat(scene, state);
|
||||
lockCasterInHurtFrame(scene, caster);
|
||||
createSpecialFocusLayer(scene, state, caster);
|
||||
spawnCasterSparkle(scene, state, caster);
|
||||
scene.beginSpecialEffectCameraFocus?.(caster);
|
||||
|
||||
addRealtimeStateTimer(state, SPECIAL_EFFECT.CASTER.HURT_HOLD_MS, () => {
|
||||
resumeSpecialPreparationCombat(scene, state);
|
||||
disposeCasterSparkle(scene, state);
|
||||
|
||||
if (!isSpecialCastValid(scene, caster, matchId)) {
|
||||
cleanupSpecialCastState(scene, state, { restoreCaster: true });
|
||||
|
|
@ -498,6 +521,7 @@ function createSpecialCastState(scene, caster, matchId) {
|
|||
disposed: false,
|
||||
hitFighters: new Set(),
|
||||
matchId,
|
||||
casterSparkle: null,
|
||||
projectile: null,
|
||||
timers: new Set(),
|
||||
};
|
||||
|
|
@ -529,6 +553,8 @@ function cleanupSpecialCastState(
|
|||
state.attackCompleteHandler = null;
|
||||
}
|
||||
|
||||
disposeCasterSparkle(scene, state);
|
||||
|
||||
if (disposeProjectile && state.projectile?.active) {
|
||||
disposeCombatObject(scene, state.projectile);
|
||||
}
|
||||
|
|
@ -870,6 +896,110 @@ function restoreSpecialFocusCasterDepth(state, focusLayer) {
|
|||
}
|
||||
}
|
||||
|
||||
function spawnCasterSparkle(scene, state, caster) {
|
||||
const config = SPECIAL_EFFECT.CASTER_SPARKLE;
|
||||
|
||||
if (!config.ENABLED || state.casterSparkle || !scene.textures.exists(config.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const casterScale = resolveCasterScale(caster);
|
||||
const sparkleScale = resolveCasterSparkleScale(casterScale, config);
|
||||
const sparkle = scene.add
|
||||
.sprite(0, 0, config.key, resolveFirstSpecialFrame(config))
|
||||
.setOrigin(
|
||||
resolveAnchorRatio(config.effectAnchorX, config.frameWidth),
|
||||
resolveAnchorRatio(config.effectAnchorY, config.frameHeight),
|
||||
)
|
||||
.setDepth(caster.depth + (Number(config.depthOffset) || 0))
|
||||
.setScale(sparkleScale)
|
||||
.setAlpha(Phaser.Math.Clamp(Number(config.alpha) || 1, 0, 1))
|
||||
.setBlendMode(Phaser.BlendModes.ADD);
|
||||
|
||||
const syncSparklePosition = () => {
|
||||
if (!sparkle.active || !caster.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = caster.flipX ? -1 : 1;
|
||||
const position = resolveCasterSparklePosition(caster, config, casterScale);
|
||||
|
||||
sparkle.setPosition(
|
||||
caster.x + position.x * direction,
|
||||
caster.y + position.y,
|
||||
);
|
||||
};
|
||||
|
||||
syncSparklePosition();
|
||||
sparkle.play(specialAnimationKey(config.key));
|
||||
scene.events.on(Phaser.Scenes.Events.UPDATE, syncSparklePosition);
|
||||
trackCombatObject(scene, sparkle);
|
||||
|
||||
sparkle.cleanup = () => {
|
||||
scene.events.off(Phaser.Scenes.Events.UPDATE, syncSparklePosition);
|
||||
|
||||
if (state.casterSparkle === sparkle) {
|
||||
state.casterSparkle = null;
|
||||
}
|
||||
};
|
||||
state.casterSparkle = sparkle;
|
||||
}
|
||||
|
||||
function disposeCasterSparkle(scene, state) {
|
||||
if (state?.casterSparkle?.active) {
|
||||
disposeCombatObject(scene, state.casterSparkle);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCasterScale(caster) {
|
||||
return Math.max(
|
||||
Math.abs(caster.scaleX ?? 1),
|
||||
Math.abs(caster.scaleY ?? 1),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCasterSparkleScale(casterScale, config) {
|
||||
const multiplier = Math.max(0, Number(config.scaleMultiplier) || 1);
|
||||
|
||||
return casterScale * multiplier;
|
||||
}
|
||||
|
||||
function resolveCasterSparklePosition(caster, config, casterScale) {
|
||||
const originX = Number.isFinite(caster.originX) ? caster.originX : 0.5;
|
||||
const originY = Number.isFinite(caster.originY) ? caster.originY : 0.5;
|
||||
const anchorX = Number.isFinite(config.anchorX)
|
||||
? config.anchorX
|
||||
: FIGHTER.FRAME_WIDTH * originX;
|
||||
const anchorY = Number.isFinite(config.anchorY)
|
||||
? config.anchorY
|
||||
: FIGHTER.FRAME_HEIGHT * originY;
|
||||
|
||||
return {
|
||||
x: (anchorX - FIGHTER.FRAME_WIDTH * originX) * casterScale,
|
||||
y: (anchorY - FIGHTER.FRAME_HEIGHT * originY) * casterScale,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnchorRatio(anchor, size) {
|
||||
const safeSize = Math.max(1, Number(size) || 1);
|
||||
const safeAnchor = Number.isFinite(anchor) ? anchor : safeSize / 2;
|
||||
|
||||
return Phaser.Math.Clamp(safeAnchor / safeSize, 0, 1);
|
||||
}
|
||||
|
||||
function resolveFirstSpecialFrame(config) {
|
||||
const firstFrameNumber = Array.isArray(config.frameSequence)
|
||||
? config.frameSequence[0]
|
||||
: 1;
|
||||
|
||||
return Phaser.Math.Clamp(
|
||||
Math.round(Number(firstFrameNumber) || 1) - 1,
|
||||
0,
|
||||
Math.max(0, (Number(config.frames) || 1) - 1),
|
||||
);
|
||||
}
|
||||
|
||||
function selectSpecialCaster(scene) {
|
||||
const livingFighters = scene.fighters.filter(isLivingFighter);
|
||||
const summaries = livingTeamSummaries(livingFighters);
|
||||
|
|
|
|||
Loading…
Reference in New Issue