238 lines
7.4 KiB
JavaScript
238 lines
7.4 KiB
JavaScript
/**
|
||
* Visuelle Rückmeldung: Explosionen, Funken, Rauch, Brandspuren, Mündungsfeuer,
|
||
* schwebende Texte und Bildschirmwackeln.
|
||
*
|
||
* Rückmeldung ist kein Luxus: Ohne Treffer-Aufblitzen und Funken fühlt sich Schießen
|
||
* „hohl“ an und man sieht nicht, ob man überhaupt trifft. Sind die Kenney-Partikel
|
||
* nicht geladen, fällt alles auf einfache Kreise zurück.
|
||
* @module systems/Effects
|
||
*/
|
||
|
||
import { COLORS, FX, GAME } from '../config.js';
|
||
import { UI } from '../ui/theme.js';
|
||
import { TEXTURE_KEYS } from './Placeholders.js';
|
||
|
||
/** Kenney-Partikel sind 128 px groß; auf diese Größen wird skaliert. */
|
||
const PARTICLE_SOURCE_SIZE = 128;
|
||
/** Höchstzahl gleichzeitig sichtbarer Brandspuren (älteste verschwinden zuerst). */
|
||
const MAX_SCORCH_MARKS = 40;
|
||
const SCORCH_LIFETIME_MS = 20000;
|
||
const MUZZLE_FLASH_MS = 70;
|
||
|
||
export class Effects {
|
||
/** @param {Phaser.Scene} scene */
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
const has = (key) => scene.textures.exists(key);
|
||
this.hasKenney = has('fx-spark') && has('fx-smoke') && has('fx-fire');
|
||
/** @type {Phaser.GameObjects.Image[]} */
|
||
this.scorchMarks = [];
|
||
|
||
// Gemeinsame Emitter: deutlich günstiger als ein Emitter pro Treffer.
|
||
const sparkKey = has('fx-spark') ? 'fx-spark' : TEXTURE_KEYS.particle;
|
||
const sparkScale = has('fx-spark') ? 14 / PARTICLE_SOURCE_SIZE : 0.9;
|
||
this.sparks = scene.add.particles(0, 0, sparkKey, {
|
||
lifespan: 380,
|
||
speed: { min: 80, max: 280 },
|
||
scale: { start: sparkScale, end: 0 },
|
||
alpha: { start: 1, end: 0 },
|
||
rotate: { min: 0, max: 360 },
|
||
blendMode: 'ADD',
|
||
emitting: false,
|
||
});
|
||
this.sparks.setDepth(GAME.depth.effects);
|
||
|
||
if (this.hasKenney) {
|
||
this.fire = scene.add.particles(0, 0, 'fx-fire', {
|
||
lifespan: 450,
|
||
speed: { min: 20, max: 140 },
|
||
scale: { start: 0.35, end: 0 },
|
||
alpha: { start: 0.9, end: 0 },
|
||
rotate: { min: 0, max: 360 },
|
||
blendMode: 'ADD',
|
||
emitting: false,
|
||
});
|
||
this.smoke = scene.add.particles(0, 0, 'fx-smoke', {
|
||
lifespan: 1100,
|
||
speed: { min: 10, max: 60 },
|
||
scale: { start: 0.3, end: 0.9 },
|
||
alpha: { start: 0.45, end: 0 },
|
||
rotate: { min: 0, max: 360 },
|
||
tint: 0x555555,
|
||
emitting: false,
|
||
});
|
||
this.fire.setDepth(GAME.depth.effects);
|
||
this.smoke.setDepth(GAME.depth.effects - 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Funkenregen an einer Stelle.
|
||
* @param {number} x
|
||
* @param {number} y
|
||
* @param {number} color
|
||
* @param {number} [count]
|
||
*/
|
||
burst(x, y, color, count = 8) {
|
||
this.sparks.setParticleTint(color);
|
||
this.sparks.explode(count, x, y);
|
||
}
|
||
|
||
/**
|
||
* Explosion mit Feuer, Rauch, Lichtblitz und Brandspur. Der Ring zeigt den
|
||
* tatsächlichen Wirkungsradius, damit Spieler lernen, wie weit Schaden reicht.
|
||
* @param {number} x
|
||
* @param {number} y
|
||
* @param {number} radius
|
||
* @param {number} color
|
||
*/
|
||
explosion(x, y, radius, color) {
|
||
const ring = this.scene.add.circle(x, y, radius, color, 0.22).setDepth(GAME.depth.effects);
|
||
ring.setStrokeStyle(4, color, 0.9);
|
||
ring.setScale(0.15);
|
||
this.scene.tweens.add({
|
||
targets: ring,
|
||
scale: 1,
|
||
alpha: 0,
|
||
duration: 380,
|
||
ease: 'Cubic.easeOut',
|
||
onComplete: () => ring.destroy(),
|
||
});
|
||
|
||
if (this.hasKenney) {
|
||
const amount = Math.min(28, Math.round(radius / 6));
|
||
this.fire.explode(amount, x, y);
|
||
this.smoke.explode(Math.ceil(amount / 2), x, y);
|
||
this.glow(x, y, radius * 1.6, color);
|
||
this.scorch(x, y, radius);
|
||
}
|
||
this.burst(x, y, color, Math.min(40, Math.round(radius / 5)));
|
||
this.burst(x, y, COLORS.cream, 8);
|
||
this.shake('explosion');
|
||
}
|
||
|
||
/** Kurzer, additiver Lichtblitz. */
|
||
glow(x, y, size, color) {
|
||
if (!this.scene.textures.exists('fx-glow')) return;
|
||
const flash = this.scene.add
|
||
.image(x, y, 'fx-glow')
|
||
.setDisplaySize(size, size)
|
||
.setTint(color)
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(GAME.depth.effects);
|
||
this.scene.tweens.add({ targets: flash, alpha: 0, duration: 260, onComplete: () => flash.destroy() });
|
||
}
|
||
|
||
/** Brandspur auf dem Boden, die langsam verblasst. */
|
||
scorch(x, y, radius) {
|
||
if (!this.scene.textures.exists('fx-scorch')) return;
|
||
const mark = this.scene.add
|
||
.image(x, y, 'fx-scorch')
|
||
.setDisplaySize(radius * 2.2, radius * 2.2)
|
||
.setAngle(Math.random() * 360)
|
||
.setTint(0x1a1410)
|
||
.setAlpha(0.55)
|
||
.setDepth(GAME.depth.ground + 2);
|
||
this.scorchMarks.push(mark);
|
||
if (this.scorchMarks.length > MAX_SCORCH_MARKS) this.scorchMarks.shift()?.destroy();
|
||
this.scene.tweens.add({
|
||
targets: mark,
|
||
alpha: 0,
|
||
delay: SCORCH_LIFETIME_MS,
|
||
duration: 2000,
|
||
onComplete: () => {
|
||
this.scorchMarks = this.scorchMarks.filter((m) => m !== mark);
|
||
mark.destroy();
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Aufsteigender, verblassender Text (Punkte, Meldungen am Ort).
|
||
* @param {number} x
|
||
* @param {number} y
|
||
* @param {string} text
|
||
* @param {string} [color]
|
||
* @param {number} [fontSize]
|
||
*/
|
||
floatingText(x, y, text, color = '#f2c14e', fontSize = 18) {
|
||
const label = this.scene.add
|
||
.text(x, y, text, {
|
||
fontFamily: UI.uiFont,
|
||
fontStyle: 'bold',
|
||
fontSize: `${fontSize}px`,
|
||
color,
|
||
stroke: '#0d1321',
|
||
strokeThickness: 4,
|
||
})
|
||
.setOrigin(0.5)
|
||
.setDepth(GAME.depth.effects);
|
||
this.scene.tweens.add({
|
||
targets: label,
|
||
y: y - 36,
|
||
alpha: 0,
|
||
duration: FX.damageNumberMs,
|
||
ease: 'Quad.easeOut',
|
||
onComplete: () => label.destroy(),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Kamera wackeln lassen.
|
||
* @param {'hit'|'explosion'} kind
|
||
*/
|
||
shake(kind) {
|
||
const cfg = FX.screenShake[kind];
|
||
this.scene.cameras.main.shake(cfg.durationMs, cfg.intensity);
|
||
}
|
||
|
||
/**
|
||
* Lässt ein Sprite kurz weiß aufblitzen (Treffer-Feedback).
|
||
* @param {Phaser.GameObjects.Sprite} sprite
|
||
*/
|
||
flash(sprite) {
|
||
sprite.setTintFill(0xffffff);
|
||
this.scene.time.delayedCall(FX.hitFlashMs, () => {
|
||
if (sprite.active) sprite.clearTint();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Nachbild einer Figur (Ausweichrolle): verdeutlicht Richtung und Tempo der Bewegung.
|
||
* @param {Phaser.GameObjects.Sprite} sprite
|
||
*/
|
||
afterimage(sprite) {
|
||
const ghost = this.scene.add
|
||
.image(sprite.x, sprite.y, sprite.texture.key)
|
||
.setRotation(sprite.rotation)
|
||
.setAlpha(0.45)
|
||
.setTint(COLORS.gold)
|
||
.setDepth(sprite.depth - 1);
|
||
this.scene.tweens.add({ targets: ghost, alpha: 0, scale: 1.15, duration: 260, onComplete: () => ghost.destroy() });
|
||
}
|
||
|
||
/**
|
||
* Mündungsfeuer an der Waffenspitze.
|
||
* @param {number} x
|
||
* @param {number} y
|
||
* @param {number} angle Schussrichtung (Bogenmaß).
|
||
* @param {number} [color]
|
||
*/
|
||
muzzle(x, y, angle, color = COLORS.playerBullet) {
|
||
if (this.scene.textures.exists('fx-muzzle')) {
|
||
// Die Kenney-Grafik zeigt nach oben – um 90° drehen, damit sie in Schussrichtung zeigt.
|
||
const flash = this.scene.add
|
||
.image(x, y, 'fx-muzzle')
|
||
.setOrigin(0.5, 0.9)
|
||
.setRotation(angle + Math.PI / 2)
|
||
.setScale(0.26)
|
||
.setTint(color)
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(GAME.depth.effects);
|
||
this.scene.tweens.add({ targets: flash, alpha: 0, duration: MUZZLE_FLASH_MS, onComplete: () => flash.destroy() });
|
||
} else {
|
||
this.burst(x, y, color, 3);
|
||
}
|
||
}
|
||
}
|