Coverage Summary for Class: FireParticleSystem (it.polimi.ingsw.gc14.View.GUI)
| Class |
Method, %
|
Branch, %
|
Line, %
|
| FireParticleSystem |
0%
(0/5)
|
0%
(0/29)
|
0%
(0/49)
|
| FireParticleSystem$1 |
0%
(0/2)
|
0%
(0/2)
|
0%
(0/4)
|
| FireParticleSystem$Particle |
0%
(0/7)
|
0%
(0/40)
|
0%
(0/69)
|
| Total |
0%
(0/14)
|
0%
(0/71)
|
0%
(0/122)
|
package it.polimi.ingsw.gc14.View.GUI;
import javafx.animation.AnimationTimer;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
/**
* Ambient fire-particle effect rendered on a transparent overlay pane.
*
* <p>Three particle types float from the screen corners toward the center:
* <ul>
* <li><b>embers</b> (type 0) — slow, glowing orange circles with a soft halo.</li>
* <li><b>sparks</b> (type 1) — fast, short-lived bright particles.</li>
* <li><b>dust</b> (type 2) — large, translucent brown drifting circles.</li>
* </ul>
*
* <p>Call {@link #start()} after adding {@link #getPane()} to the scene graph
*/
public class FireParticleSystem {
/** Maximum number of live particles at any given time. */
private static final int MAX_PARTICLES = 30;
/** Minimum nanoseconds between particle spawn batches. */
private static final long SPAWN_INTERVAL_NS = 100_000_000L;
/** Minimum nanoseconds between rendered frames (30 fps cap). */
private static final long FRAME_INTERVAL_NS = 1_000_000_000L / 30;
/** Physics update steps applied per rendered frame (multiplies effective speed). */
private static final int STEPS_PER_FRAME = 3;
/** Mouse-transparent overlay pane that hosts all particle nodes. */
private final Pane pane;
/** The scene whose dimensions are used to position and fade particles. */
private final Scene scene;
/** Currently live particles. */
private final List<Particle> particles = new ArrayList<>();
/** Shared random source for particle initialization and physics noise. */
private final Random rnd = new Random();
/** Timestamp (ns) of the last particle spawn batch. */
private long lastSpawn = 0;
/** Timestamp (ns) of the last rendered frame. */
private long lastFrame = 0;
/** {@code true} after the first tick pre-populates the particle pool. */
private boolean warmedUp = false;
/**
* Creates the particle system bound to the given scene.
* The internal overlay pane is transparent and mouse-transparent.
*
* @param scene the scene whose dimensions are used for particle positioning and fading.
*/
public FireParticleSystem(Scene scene) {
this.scene = scene;
pane = new Pane();
pane.setMouseTransparent(true);
pane.setPickOnBounds(false);
}
/**
* Returns the transparent overlay pane that holds all particle nodes.
*
* @return the mouse-transparent {@link Pane} overlay.
*/
public Pane getPane() { return pane; }
/** Starts the animation timer. */
public void start() {
AnimationTimer timer = new AnimationTimer() {
@Override
public void handle(long now) {
if (now - lastFrame < FRAME_INTERVAL_NS) return;
lastFrame = now;
tick(now);
}
};
timer.start();
}
/**
* Advances the simulation by one frame: pre-warms on first call, spawns new particles,
* steps existing ones, removes dead ones, and updates their visual state.
*
* @param now the current timestamp in nanoseconds from the animation timer.
*/
private void tick(long now) {
double w = scene.getWidth();
double h = scene.getHeight();
if (w == 0 || h == 0) return;
if (!warmedUp) {
warmedUp = true;
for (int i = 0; i < MAX_PARTICLES; i++) {
Particle p = spawnParticle(w, h);
int advance = rnd.nextInt(800) + 100;
for (int f = 0; f < advance; f++) p.update();
if (!p.isDead(w, h)) {
p.addTo(pane);
p.updateVisual(w, h);
particles.add(p);
}
}
}
if (now - lastSpawn > SPAWN_INTERVAL_NS && particles.size() < MAX_PARTICLES) {
lastSpawn = now;
int count = rnd.nextInt(2) + 1;
for (int i = 0; i < count && particles.size() < MAX_PARTICLES; i++) {
Particle p = spawnParticle(w, h);
p.addTo(pane);
particles.add(p);
}
}
Iterator<Particle> it = particles.iterator();
while (it.hasNext()) {
Particle p = it.next();
for (int s = 0; s < STEPS_PER_FRAME; s++) p.update();
if (p.isDead(w, h)) {
p.removeFrom(pane);
it.remove();
} else {
p.updateVisual(w, h);
}
}
}
/**
* Creates a new particle starting near one of the four screen corners.
*
* @param w scene width in pixels.
* @param h scene height in pixels.
* @return the newly constructed {@link Particle}.
*/
private Particle spawnParticle(double w, double h) {
int corner = rnd.nextInt(4);
double margin = 0.12;
double x, y;
switch (corner) {
case 0 -> { x = rnd.nextDouble() * w * margin; y = h - rnd.nextDouble() * h * margin; }
case 1 -> { x = w - rnd.nextDouble() * w * margin; y = h - rnd.nextDouble() * h * margin; }
case 2 -> { x = rnd.nextDouble() * w * margin; y = rnd.nextDouble() * h * margin; }
default -> { x = w - rnd.nextDouble() * w * margin; y = rnd.nextDouble() * h * margin; }
}
return new Particle(x, y, w, h, corner, rnd);
}
/**
* A single fire particle with position, velocity, wobble, and JavaFX visual nodes.
* Particles are typed: 0 = ember, 1 = spark, 2 = dust.
*/
private static class Particle {
/** Current X position in scene pixels. */
double x;
/** Current Y position in scene pixels. */
double y;
/** Horizontal velocity component. */
double vx;
/** Vertical velocity component. */
double vy;
/** Remaining life fraction (1.0 = full, 0.0 = dead); only decrements for sparks. */
double life;
/** Visual radius of the particle's core circle. */
final double size;
/** Current phase of the sinusoidal wobble. */
double wobblePhase;
/** Angular speed of the wobble oscillation. */
final double wobbleSpeed;
/** Amplitude factor of the wobble displacement. */
final double wobbleAmp;
/** Particle type: 0 = ember, 1 = spark, 2 = dust. */
final int type;
/** Shared random source used during physics updates. */
final Random rnd;
/** Primary visible circle node. */
final Circle core;
/** Soft glow halo circle behind {@link #core}; non-null only for embers (type 0). */
final Circle glow;
/**
* Initializes the particle at position ({@code x}, {@code y}) with velocity
* aimed roughly from the given {@code corner} toward the scene center.
*
* @param x spawn X coordinate.
* @param y spawn Y coordinate.
* @param w scene width (used to compute target direction).
* @param h scene height (used to compute target direction).
* @param corner spawn corner index: 0 = bottom-left, 1 = bottom-right, 2 = top-left, 3 = top-right.
* @param rnd shared random source.
*/
Particle(double x, double y, double w, double h, int corner, Random rnd) {
this.x = x;
this.y = y;
this.life = 1.0;
this.rnd = rnd;
this.type = weightedType(rnd);
double targetX = (corner == 0 || corner == 2) ? w * 0.75 : w * 0.25;
double targetY = (corner == 0 || corner == 1) ? h * 0.25 : h * 0.75;
double baseAngle = Math.atan2(targetY - y, targetX - x);
double angle = baseAngle + (rnd.nextDouble() - 0.5) * (Math.PI / 3.5);
double speed;
if (type == 0) {
speed = 0.5 + rnd.nextDouble() * 0.7;
size = rnd.nextDouble() * 3 + 2;
glow = new Circle(size * 2.2);
core = new Circle(size / 2);
} else if (type == 1) {
speed = 1.5 + rnd.nextDouble() * 2.0;
size = rnd.nextDouble() * 1.5 + 0.5;
glow = null;
core = new Circle(size / 2);
} else {
speed = 0.2 + rnd.nextDouble() * 0.35;
size = rnd.nextDouble() * 8 + 5;
glow = null;
core = new Circle(size);
core.setFill(Color.color(0.55, 0.38, 0.22));
}
vx = Math.cos(angle) * speed;
vy = Math.sin(angle) * speed;
wobblePhase = rnd.nextDouble() * Math.PI * 2;
wobbleSpeed = 0.025 + rnd.nextDouble() * 0.04;
wobbleAmp = 0.1 + rnd.nextDouble() * 0.4;
}
/**
* Returns a particle type weighted toward embers (55% ember, 23% spark, 22% dust).
*
* @param rnd the random source.
* @return particle type: 0, 1, or 2.
*/
private static int weightedType(Random rnd) {
double r = rnd.nextDouble();
if (r < 0.55) return 0;
if (r < 0.78) return 1;
return 2;
}
/**
* Adds this particle's visual nodes to {@code pane} (glow first so core renders on top).
*
* @param pane the overlay pane to add nodes to.
*/
void addTo(Pane pane) {
if (glow != null) pane.getChildren().add(glow);
pane.getChildren().add(core);
}
/**
* Removes this particle's visual nodes from {@code pane}.
*
* @param pane the overlay pane to remove nodes from.
*/
void removeFrom(Pane pane) {
pane.getChildren().remove(core);
if (glow != null) pane.getChildren().remove(glow);
}
/** Advances this particle by one physics step: applies wobble, moves, and decrements spark life. */
void update() {
wobblePhase += wobbleSpeed;
vx += Math.sin(wobblePhase) * wobbleAmp * 0.05;
vy += Math.cos(wobblePhase) * wobbleAmp * 0.02;
x += vx;
y += vy;
if (type == 1) life -= 0.008 + rnd.nextDouble() * 0.006;
}
/**
* Returns {@code true} when this particle has left the scene bounds or, for sparks, its life reached zero.
*
* @param w scene width.
* @param h scene height.
* @return {@code true} if the particle should be removed.
*/
boolean isDead(double w, double h) {
double pad = size * 4;
return x < -pad || x > w + pad || y < -pad || y > h + pad
|| (type == 1 && life <= 0);
}
/**
* Updates color, opacity, and translate position of this particle's visual nodes
* based on distance from scene edges and remaining life.
*
* @param w scene width.
* @param h scene height.
*/
void updateVisual(double w, double h) {
double edge = Math.min(w, h) * 0.05;
double fadeX = Math.min(x / edge, Math.min((w - x) / edge, 1.0));
double fadeY = Math.min(y / edge, Math.min((h - y) / edge, 1.0));
double posAlpha = Math.max(0, Math.min(fadeX, fadeY));
double lifeAlpha = (type == 1) ? Math.min(life * 2.0, 1.0) : 1.0;
double alpha = posAlpha * lifeAlpha;
core.setTranslateX(x);
core.setTranslateY(y);
if (type == 0) {
double dist = Math.sqrt((x - w/2) * (x - w/2) + (y - h/2) * (y - h/2));
double hotness = Math.max(0, 1.0 - dist / (Math.sqrt(w * w + h * h) * 0.4));
double g = 0.3 + hotness * 0.5;
core.setFill(Color.color(1.0, g, 0.0));
core.setOpacity(alpha * 0.88);
glow.setTranslateX(x);
glow.setTranslateY(y);
glow.setFill(Color.color(1.0, g * 0.35, 0.0));
glow.setOpacity(alpha * 0.10);
} else if (type == 1) {
double brightness = Math.min(life * 2.5, 1.0);
core.setFill(Color.color(1.0, brightness * 0.85 + 0.15, brightness * 0.15));
core.setOpacity(alpha);
} else {
core.setOpacity(alpha * 0.20);
}
}
}
}