Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public final class Messages {
public static final Component SURVIVOR_WIN_MESSAGE;
public static final Component LIGHT_WENT_OUT;
public static final Component ONLY_PLAYERS_HAVE_A_VIEW;
public static final Component ONLY_PLAYERS_CAN_BLEED;
private static final Component PAGE_FOUND_PART;
private static final Component LEAVE_PART;
private static final Component JOIN_PART;
Expand Down Expand Up @@ -63,6 +64,7 @@ public final class Messages {
JOIN_PART = Component.text("joined the game!", NamedTextColor.GRAY);
LIGHT_WENT_OUT = withMiniPrefix("<color:#ff00d4>Your light went out!</color>");
ONLY_PLAYERS_HAVE_A_VIEW = withMiniPrefix("<red>Only players have a view to lose.");
ONLY_PLAYERS_CAN_BLEED = withMiniPrefix("<red>Only players can bleed.");

SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("<yellow>You are a Survivor! Find various <red>Pages").append(Component.space());

Expand Down
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.
14 changes: 9 additions & 5 deletions game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
import net.onelitefeather.cygnus.command.GlitchCommand;
import net.onelitefeather.cygnus.blood.BloodSplatterService;
import net.onelitefeather.cygnus.command.StartCommand;
import net.onelitefeather.cygnus.common.ListenerHandling;
import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap;
Expand Down Expand Up @@ -79,9 +80,6 @@
import net.onelitefeather.cygnus.resourcepack.ResourcePackService;
import net.onelitefeather.cygnus.stamina.SlenderBarTrigger;
import net.onelitefeather.cygnus.stamina.StaminaService;
import net.onelitefeather.cygnus.overlay.ScreenOverlay;
import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay;
import net.onelitefeather.cygnus.overlay.OverlayProperties;
import net.onelitefeather.cygnus.utils.StaminaHelper;
import net.onelitefeather.cygnus.utils.ViewRuleUpdater;
import net.onelitefeather.cygnus.view.GameView;
Expand All @@ -90,11 +88,12 @@

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

/**
* @author theEvilReaper
* @version 1.0.0
* @version 1.1.0
* @since 1.0.0
**/
@SuppressWarnings("java:S3252")
Expand All @@ -121,6 +120,7 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
*/
private final ScreenOverlay screenOverlay;
private final SlenderGazeService slenderGazeService;
private final BloodSplatterService bloodSplatterService;

public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
Expand All @@ -147,6 +147,10 @@ public Cygnus() {
this.screenOverlay = new EquipmentScreenOverlay();
this.slenderGazeService = new SlenderGazeService(
this.screenOverlay, () -> TeamHelper.slenderOf(this.teamService));
this.bloodSplatterService = new BloodSplatterService(
this.screenOverlay,
bound -> ThreadLocalRandom.current().nextInt(bound)
);
this.initPhases();
this.initCommands();
this.initListener();
Expand All @@ -160,7 +164,6 @@ private void initCommands() {
manager.register(new GlitchCommand(this.slenderGazeService));
}


private void initListener() {
Supplier<Phase> phaseSupplier = this.linearPhaseSeries::getCurrentPhase;
var manager = MinecraftServer.getGlobalEventHandler();
Expand Down Expand Up @@ -230,6 +233,7 @@ private void registerGameListener() {
private void registerOverlayListeners(GlobalEventHandler handler) {
if (!OverlayProperties.enabled()) return;
this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService));
this.bloodSplatterService.registerListener(handler);
}

private void initPhases() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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;

private static final BloodDirection[] VALUES = values();

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

/**
* Returns all possible values of this enum.
*
* @return values of this enum
*/
public static BloodDirection[] getValues() {
return VALUES;
}
}
Loading
Loading