Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/superpowers/specs/2026-08-11-blood-splatter-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Blood splatter on damage

## Goal

Taking a hit throws blood across the screen: it appears at once, from the side the hit came from,
and fades away within about a second. It says nothing about how the player is doing — that is the
tunnel vision's job — it only says *you were just hit, from over there*.

## Sharing the screen with the tunnel vision

Both effects are full-screen overlays, and both are drawn as the `camera_overlay` of an item on the
player's head — the only mechanism in vanilla that scales a texture to the viewport instead of
being calibrated against one resolution.

A player has one head, so **only one overlay can be shown at a time.** A `ScreenOverlay` owns the
head slot and decides: each effect hands it a texture for its layer (`OverlayLayer.TUNNEL_VISION`,
`OverlayLayer.BLOOD`, in drawing order) and the topmost one wins. A splatter therefore takes the
screen for the 1.2 seconds it lasts, and the tunnel vision comes back underneath it afterwards.

The alternative was pre-rendering every combination of splatter frame and vignette stage, so both
stay visible at once. That is 48 × 4 extra images at the coarsest useful resolution, and every
change to either effect would force re-rendering all of them.

Two smaller things follow from riding on an item: the carrier points its `asset_id` at an empty
equipment model so it is never drawn on the player's head, and the overlay is only re-sent when the
texture actually changes — an equipment update goes out to every viewer, not just the wearer.

## Trigger

Cygnus applies damage in `SlenderBarHelper.applyDamage` by setting health directly. That never
raises Minestom's `EntityDamageEvent`, so a listener on it would never fire.

`applyDamage` therefore dispatches a `PlayerDamagedEvent` carrying the victim, the source position
and the amount — the same shape the project already uses for `StaminaStateChangeEvent` and
`SlenderReviveEvent`. The source position is what lets the splatter be aimed; the amount is not
used yet but is the natural handle for anything that should scale with how hard the hit was.

## Direction

`BloodDirection.between(victim, source)` reduces the hit to one of four sides, seen from the victim
rather than from the world:

```
alignment = dot(victimLookDirection, directionToSource)
alignment > 0.5 -> FRONT
alignment < -0.5 -> BACK
cross(facing, towardsSource).y > 0 -> LEFT, else RIGHT
```

A hit from the east lands on the left for a player looking south and on the right for one looking
north. From the exact same spot the direction is meaningless, so it falls back to FRONT.

## Frames

Textures are laid out as direction × variant × frame: 4 × 2 × 6 = 48. The variants keep repeated
hits from looking mechanical, and the frames are the fade — Minecraft cannot animate a camera
overlay, so the server steps through them, one every 200 ms, giving a splatter that lives 1.2
seconds. A fresh hit restarts the sequence rather than queueing behind the old one.

The task that drives the fade starts with the first splatter and stops once nothing is bleeding
any more, rather than spinning over an empty map between hits.

Drawings are generated by `tools/generate_overlay.py` in `cygnus-pack`: drops are placed with a
power-law radius — many specks, few real blotches — weighted towards the side the hit came from,
then blurred and thresholded so they melt into shapes with ragged edges instead of reading as
confetti. Bigger blotches grow a run downwards that lengthens as the frame fades, and a band along
the edge the hit came from seals the gaps the drops leave — without it a side splatter looks like
it stops short of the border. Textures are 1024×576, matching the 16:9 they are stretched onto.

## Wiring

`Cygnus` creates the service and `/blood`, and the service listens for itself:

| Event | What happens |
| --- | --- |
| `PlayerDamagedEvent` | throws a splatter from the direction of the source |
| `PlayerDisconnectEvent` | drops the player's splatter |

Like the tunnel vision, it is only registered when a resource pack is configured — without the
pack the textures are missing and players would get a fullscreen missing-texture checkerboard.

`/blood [front|right|back|left]` throws one on demand, with no side meaning a random one, so the
drawings can be judged without waiting to be hit.

## Failure modes

| Situation | Behaviour |
| --- | --- |
| Hit while a splatter is still fading | the old one is replaced, the sequence restarts |
| Hit from the victim's own position | falls back to `FRONT` |
| Player leaves mid-fade | the splatter is dropped with them |
| Tunnel vision changes during a splatter | the splatter keeps the screen; the new stage shows once it is over |

## Tests

- `BloodDirectionTest` — plain JUnit: each of the four sides, that the victim's facing decides
rather than the world, and the degenerate same-spot case.
- `BloodSplatterServiceTest` — the first frame appears immediately, the fade walks the frames and
cleans up, a second hit restarts, the damage event triggers it, players are independent.
- `SlenderBarHelperDamageTest` — damage announces the victim and the source, and leaves out the
player who dealt it.
- `BloodCommandTest` — every side can be requested, and the bare command picks one.
- `EquipmentScreenOverlayTest` — the blood wins over the tunnel vision, the tunnel vision returns
afterwards, the slot empties with the last layer, and an unchanged overlay is not re-sent.
20 changes: 20 additions & 0 deletions game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
import net.minestom.server.listener.EntityActionListener;
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
import net.onelitefeather.cygnus.blood.BloodSplatterService;
import net.onelitefeather.cygnus.command.BloodCommand;
import net.onelitefeather.cygnus.command.StartCommand;
import net.onelitefeather.cygnus.common.ListenerHandling;
import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap;
Expand Down Expand Up @@ -66,6 +68,9 @@
import net.onelitefeather.cygnus.movement.CygnusEntityActionListener;
import net.onelitefeather.cygnus.movement.PlayerStartSprintingEvent;
import net.onelitefeather.cygnus.movement.PlayerStopSprintingEvent;
import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay;
import net.onelitefeather.cygnus.overlay.OverlayProperties;
import net.onelitefeather.cygnus.overlay.ScreenOverlay;
import net.onelitefeather.cygnus.phase.GamePhase;
import net.onelitefeather.cygnus.phase.LobbyPhase;
import net.onelitefeather.cygnus.phase.RestartPhase;
Expand All @@ -82,6 +87,7 @@

import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;

/**
Expand All @@ -103,6 +109,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final JumpScareManager jumpscareManager;
private final SpectatorService spectatorService;
private final Optional<ResourcePackService> resourcePackService;
private final ScreenOverlay screenOverlay;
private final BloodSplatterService bloodSplatterService;

public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
Expand All @@ -126,6 +134,11 @@ public Cygnus() {
.orElseThrow(() -> new IllegalStateException("Spectator team not found"));
this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam);
this.resourcePackService = ResourcePackService.create();
this.screenOverlay = new EquipmentScreenOverlay();
this.bloodSplatterService = new BloodSplatterService(
this.screenOverlay,
bound -> ThreadLocalRandom.current().nextInt(bound)
);
this.initPhases();
this.initCommands();
this.initListener();
Expand All @@ -136,6 +149,7 @@ public Cygnus() {
private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
manager.register(new BloodCommand(this.bloodSplatterService));
}


Expand Down Expand Up @@ -191,6 +205,12 @@ private void registerGameListener() {
MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::listener);

spectatorService.registerListener(handler);

// Without the pack the splatter textures do not exist, so the effect stays off wherever
// the pack is not delivered.
if (OverlayProperties.enabled()) {
this.bloodSplatterService.registerListener(handler);
}
}

private void initPhases() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package net.onelitefeather.cygnus.blood;

import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;

/**
* The side of the screen a splatter is thrown from, seen from the victim rather than from the
* world — being hit from the east means something different depending on where you are looking.
*
* @author TheMeinerLP
* @version 1.0.0
* @since 2.7.0
*/
public enum BloodDirection {

FRONT,
RIGHT,
BACK,
LEFT;

/** Above this alignment with the view direction a hit counts as coming from straight ahead. */
private static final double FORWARD_THRESHOLD = 0.5D;

/** Below this distance the direction to the source carries no meaning any more. */
private static final double DISTANCE_EPSILON = 1.0E-6D;

/**
* Works out which side a hit came from.
*
* @param victim the victim's position, whose yaw and pitch supply the view direction
* @param source where the damage came from
* @return the side to throw the splatter from
*/
public static BloodDirection between(Pos victim, Point source) {
double distance = victim.distance(source);
if (distance < DISTANCE_EPSILON) return FRONT;

Vec towardsSource = new Vec(
source.x() - victim.x(),
source.y() - victim.y(),
source.z() - victim.z()
).div(distance);
Vec facing = victim.direction();

double alignment = facing.dot(towardsSource);
if (alignment > FORWARD_THRESHOLD) return FRONT;
if (alignment < -FORWARD_THRESHOLD) return BACK;

// The cross product points up when the source sits on the side the victim's left hand is
// on, which for a player looking south is the east.
return facing.cross(towardsSource).y() > 0 ? LEFT : RIGHT;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package net.onelitefeather.cygnus.blood;

import net.kyori.adventure.key.Key;
import net.minestom.server.entity.Player;
import net.minestom.server.event.Event;
import net.minestom.server.event.EventNode;
import net.minestom.server.event.player.PlayerDisconnectEvent;
import net.onelitefeather.cygnus.common.util.PlayerState;
import net.onelitefeather.cygnus.common.util.RepeatingTask;
import net.onelitefeather.cygnus.event.PlayerDamagedEvent;
import net.onelitefeather.cygnus.overlay.OverlayLayer;
import net.onelitefeather.cygnus.overlay.OverlayTextureKeys;
import net.onelitefeather.cygnus.overlay.ScreenOverlay;

import java.time.temporal.ChronoUnit;
import java.util.Iterator;
import java.util.Locale;
import java.util.function.IntUnaryOperator;

/**
* Throws a splatter of blood across the screen when a player is hit and fades it out again.
* <p>
* The textures are laid out as direction × variant × frame. The direction aims the splatter at the
* side the hit came from, the variant keeps repeated hits from looking mechanical, and the frames
* are the fade — Minecraft cannot animate a camera overlay, so the server steps through them.
* </p>
*
* @author TheMeinerLP
* @version 2.1.0
* @since 2.7.0
*/
public final class BloodSplatterService {

/** How many drawings exist per direction. */
static final int VARIANTS = 2;

/** How many frames a splatter fades over. */
static final int FRAMES = 12;

/**
* How long a single frame stays on screen. Twelve frames at this rate keep the splatter alive
* for the same 1.2 seconds as six did at twice the interval, but it runs down the screen
* smoothly rather than in visible steps.
*/
static final int FRAME_MILLIS = 100;

/** Where the splatter textures live, as {@code camera_overlay} resolves them. */
static final String TEXTURE_PATH = "gui/blood/";

/** The keys, indexed {@code [direction][variant][frame]}. */
private static final Key[][][] TEXTURES = buildTextures();

private final ScreenOverlay overlay;
private final IntUnaryOperator variantPicker;
private final PlayerState<Splatter> active = new PlayerState<>();

/** Fades every active splatter forward by one frame. Runs only while someone is bleeding. */
final RepeatingTask fadeTask = new RepeatingTask(this::tick);

/**
* Creates a new service.
*
* @param overlay the overlay that owns the player's screen
* @param variantPicker picks a variant below the given bound
*/
public BloodSplatterService(ScreenOverlay overlay, IntUnaryOperator variantPicker) {
this.overlay = overlay;
this.variantPicker = variantPicker;
}

/**
* Listens for hits and for players leaving.
*
* @param node the node to register on
*/
public void registerListener(EventNode<Event> node) {
node.addListener(PlayerDamagedEvent.class, event -> this.splatter(
event.getPlayer(),
BloodDirection.between(event.getPlayer().getPosition(), event.getSource())
));
node.addListener(PlayerDisconnectEvent.class, event -> this.clear(event.getPlayer()));
}

/**
* Throws a fresh splatter, replacing whatever is still fading.
*
* @param player the player who was hit
* @param direction the side the hit came from
*/
public void splatter(Player player, BloodDirection direction) {
Splatter splatter = new Splatter(player, direction, this.variantPicker.applyAsInt(VARIANTS));
this.active.put(player, splatter);
this.draw(splatter);
this.fadeTask.start(FRAME_MILLIS, ChronoUnit.MILLIS);
}

/**
* Takes the splatter off a player's screen.
*
* @param player the player to clear
*/
public void clear(Player player) {
if (this.active.remove(player) == null) return;
this.overlay.set(player, OverlayLayer.BLOOD, null);
}

/**
* Advances every splatter by one frame and drops the ones that have faded out.
*/
void tick() {
Iterator<Splatter> splatters = this.active.values().iterator();
while (splatters.hasNext()) {
Splatter splatter = splatters.next();
splatter.frame++;

if (splatter.frame >= FRAMES) {
splatters.remove();
this.overlay.set(splatter.player, OverlayLayer.BLOOD, null);
continue;
}
this.draw(splatter);
}

// Nothing is bleeding; the task would only spin over an empty map until the next hit.
if (this.active.isEmpty()) this.fadeTask.stop();
}

/**
* Puts a splatter's current frame on its player's screen.
*
* @param splatter the splatter to draw
*/
private void draw(Splatter splatter) {
this.overlay.set(splatter.player, OverlayLayer.BLOOD,
TEXTURES[splatter.direction.ordinal()][splatter.variant][splatter.frame]);
}

/**
* Builds the texture key for every cell of the direction × variant × frame grid.
*
* @return the keys, indexed {@code [direction][variant][frame]}
*/
private static Key[][][] buildTextures() {
return OverlayTextureKeys.cube(
TEXTURE_PATH,
BloodDirection.values().length, VARIANTS, FRAMES,
direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT),
OverlayTextureKeys.ONE_BASED,
OverlayTextureKeys.ONE_BASED
);
}

/**
* One player's running splatter.
*/
private static final class Splatter {

private final Player player;
private final BloodDirection direction;
private final int variant;
private int frame;

private Splatter(Player player, BloodDirection direction, int variant) {
this.player = player;
this.direction = direction;
this.variant = variant;
}
}
}
Loading
Loading