/** * 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); } } }