Skip to content

feat(tunnel-vision): add survivor tunnel vision effect - #178

Open
TheMeinerLP wants to merge 16 commits into
refactor/overlay-foundationfrom
feat/survivor-tunnel-vision
Open

feat(tunnel-vision): add survivor tunnel vision effect#178
TheMeinerLP wants to merge 16 commits into
refactor/overlay-foundationfrom
feat/survivor-tunnel-vision

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Summary

Adds the survivor tunnel vision effect: as a survivor's stamina drains, their field of
view narrows, driven by 32 discrete stages with hysteresis (so the overlay doesn't
flicker between adjacent stages) plus a heartbeat pulse layered on top once stamina
runs critically low.

Minecraft 26.2 has no way to switch a post-processing shader on for a single player, so
the effect is delivered as a camera_overlay rendered onto a head-slot item instead of a
shader. The vignette textures that make the overlay look correct ship separately in
cygnus-pack; this branch only wires up the rendering logic and stays off wherever
OverlayProperties reports the pack was not delivered, since without it survivors would
otherwise stare at an empty box.

/tunnelvision lets a designer freeze a single stage or run the heartbeat preview from
the lobby, so glyph sizes and pulse timing in the resource pack can be judged without
starting a round.

Stack

This PR is based on #177 (refactor/overlay-foundation) and targets that branch, not
main. It carries only the tunnel vision slice of the original mixed feat/tunnel-vision
branch — blood splatter and the slender gaze effect are split into sibling PRs.

Important: Cygnus.java wires all three effects (tunnel vision, blood, gaze) into the
same constructor and initListener() guard, so this PR conflicts with the sibling blood
and gaze PRs at that file. Whichever of the three merges second will need a rebase to
reconcile the wiring.

Test plan

  • ./gradlew test passes fully (no pre-existing failures carried over from the mixed
    branch — the two failing SlenderVisibility tests were never part of this feature)

TheMeinerLP and others added 6 commits August 12, 2026 09:17
Four different services (tunnel vision, blood splatter, slender gaze, and the
stamina bar) each hand-rolled the same start/stop guard around a Minestom
Task: a nullable field, a null-check before scheduling, and a null-check
before cancelling, repeated with small variations at every call site.

Extract that guard once as RepeatingTask so the follow-up feature branches
can adopt a single, tested implementation instead of copying the pattern a
fifth time.
Five separate hand-rolled per-player maps existed across the tunnel vision,
blood splatter, and slender gaze code, each keyed by UUID and each managing
its own put-on-join/remove-on-leave lifecycle by hand. The duplication made
every one of those call sites a place a leak or a stale-entry bug could hide.

PlayerState wraps that lifecycle once behind a small, tested type, so the
three follow-up feature branches can store their per-player values without
re-deriving the same map bookkeeping.
The tunnel vision and slender gaze code each clamped values into range with
their own Math.min(max, Math.max(min, value)) expression, which is easy to
get backwards (min/max swapped) and gives no shared place to fix it once.

Add int and double overloads of Helper.clamp so the follow-up feature
branches can call one well-tested method instead of repeating the
expression.
Three texture-key builders existed across the tunnel vision, blood splatter,
and slender gaze designs, each assembling equipment-slot texture paths with
its own naming convention (stage_<n>, <direction>_<variant>_<frame>,
level_<level>_<frame>), so nothing about them could be reused or tested
together.

Add the shared overlay package: ScreenOverlay and OverlayLayer describe
where an overlay sits and how it renders, OverlayProperties and
EquipmentScreenOverlay handle applying it to a player's equipment slot, and
OverlayTextureKeys unifies the three texture-key conventions behind one
type. This is the rendering base the three follow-up feature branches
(tunnel vision, blood splatter, slender gaze) build their per-effect logic
on top of.
The three preview commands (tunnel vision, blood, glitch) each hand-rolled
an identical private static asPlayer(CommandSender) check to narrow a
CommandSender down to a Player, differing only in the error string sent
back to the console.

Extract that check once as CommandSenders.asPlayer, a stateless static
method rather than an abstract base command, since narrowing the sender is
the only thing the three commands have in common and a shared base class
would force them into one constructor shape and inheritance chain for a
single one-line check.
@TheMeinerLP
TheMeinerLP force-pushed the feat/survivor-tunnel-vision branch from 3f28aaf to 99b8dd4 Compare August 23, 2026 09:11
PlayerState and RepeatingTask sat in common/util, but common is what game and
setup share, and setup uses neither. They move to game/utils, next to the other
game-side helpers (Items, ScoreboardDisplay, StaminaHelper, ViewRuleUpdater),
which also removes the second util package common/util vs game/utils opened up.

Helper.clamp is dropped again: its body was a straight delegation to Math.clamp,
and the codebase already calls Math.clamp directly in LobbyWaitingTask,
CygnusPlayer and ColorUtil. HelperTest covered nothing else and goes with it.

AmbientProvider now uses RepeatingTask instead of its own nullable Task field
with the guard-and-return pair. It was one of the four copies the type was
extracted from, so the extraction pays for itself here rather than only in the
effects that land on top of this branch.

RecordingScreenOverlay joins the test sources: every effect test needs the same
recording ScreenOverlay, and writing it per test class is the same duplication
this branch removes from the production code.
…lding it

asPlayer assembled "<red>Only players " + reason from a sentence fragment each
caller passed in. That put message assembly in a command helper while every
other player-facing text in the project is a Component in Messages, and it left
each caller holding half a sentence that only made sense once concatenated here.

It now takes the finished Component and only sends it. The callers land in the
three follow-up PRs and each bring their own Messages entry.
The three effect branches each added `this.screenOverlay = new
EquipmentScreenOverlay()` to the same constructor, and two of them added a
byte-identical `currentSurvivors()` to the same class. Merging them meant
resolving the same lines three times, where taking both sides gives either a
duplicate method - loud, a compile error - or three separate overlay instances,
which is silent: a player wears one head slot, so three overlays would each
write it without knowing about the others and the last writer would win.

Cygnus creates the one overlay here instead, and the roster lookups move to
TeamHelper as survivorsOf/slenderOf, next to the other lookups that already take
a TeamService. Each effect branch then adds only its own service.

The overlay field has no reader on this branch yet, the same way CommandSenders
has no caller: this is the base of a stack, and the alternative is letting the
three branches conflict over the line that decides whether the effect works.
Each effect branch wrapped its own registration in its own
`if (OverlayProperties.enabled())`. Those blocks land on the same lines, and the
closing brace sits behind the conflict marker, so resolving one by keeping both
sides yields two opened ifs and one brace - it does not compile, which is at
least loud, but it is a conflict nobody should have to think about three times.

registerOverlayListeners holds the gate once. Each effect adds its own line to it
and nothing else, and the property named cygnus.overlays now actually governs all
of them rather than however many blocks happened to be written.
PlayerState and RepeatingTask moved to game/utils in the foundation, and
Helper.clamp is gone in favour of Math.clamp; both follow here.

clearAll() becomes cleanUp(), which is what the round teardown is called on main
(PageProvider, StaminaService, JumpScareManager).

The two player-facing texts move into Messages: the usage line as a builder, next
to the other builders that interpolate a value, and the players-only message as a
constant now that CommandSenders takes a finished Component.

show(Player, int) and hide(Player) collapse into preview(Player, int), where
SlenderGaze.NONE clears. That is not an invented sentinel - tick() already
branches on NONE coming out of SlenderGaze.levelOf, so hide() was a second
spelling of a level the domain type already had.

The recording ScreenOverlay both tests stood up is the foundation's shared one.
The comment gating the effect in Cygnus described a vignette font that no longer
exists, and OverlayProperties only named two of its three layers.
Minecraft 26.2 has no way to switch a post-processing shader on for a single
player, so the narrowing view is delivered as a camera_overlay rendered onto a
head-slot item instead. The renderer drives 32 stages with hysteresis so the
overlay does not flicker between adjacent stages, and layers a heartbeat pulse
on top once stamina runs low.

FoodBar gains a remainingShare() getter so the tunnel vision service can read
a survivor's stamina without coupling to the experience-bar display detail.

/tunnelvision lets a designer freeze a stage or run the heartbeat from the
lobby to judge the resource pack's glyph sizes without starting a round.

The vignette textures themselves ship separately in cygnus-pack; the overlay
stays off wherever OverlayProperties reports the pack is not delivered, since
without it survivors would stare at an empty box.
…already uses

PlayerState and RepeatingTask moved to game/utils in the foundation, and
Helper.clamp is gone in favour of Math.clamp; both follow here.

clearAll() becomes cleanUp(), which is what the round teardown is called on main
(PageProvider, StaminaService, JumpScareManager).

The stage count said three different things: MAX_STAGE is 32, the pack ships
stage_1 through stage_32, but the command advertised <0-16> and the design spec
described sixteen stages at 1024x576. The command now derives its usage line from
MAX_STAGE so the two cannot drift apart again, and the spec is corrected to 32
stages at 768x432 - which is what the textures on cygnus-pack's master actually
are.

The usage line itself moves into Messages as a builder, next to the other
builders that interpolate a value, and the players-only message becomes a
constant now that CommandSenders takes a finished Component.

The recording ScreenOverlay the tests stood up is the foundation's shared one.
The comment gating the effect in Cygnus described a vignette font that no longer
exists and tied the gate to the resource pack, which OverlayProperties
deliberately is not tied to.
The adapter sat as a private method in Cygnus, right after initCommands, which
put that method's closing brace inside the region the sibling effect branches
also touch: resolving the merge by keeping both sides left remainingStamina
unclosed and the registrations after it stranded inside it.

It belongs with the other stamina helpers anyway, next to initStaminaObjects and
alongside TeamHelper.survivorsOf, both of which answer the same kind of question
for a service. Cygnus keeps none of it.
The spec still described the effect as it was first drafted, before the slender
half of it became its own feature:

- "Intensity" documented a proximity term, a view factor and a combination
  formula. TunnelVisionIntensity has only fromStamina; what closed the view as
  the Slender approached is now the gaze glitch, on its own overlay layer. The
  section says why they are apart rather than pretending they are together.
- "Wiring" claimed the service exists only when a resource pack is configured.
  It is gated by OverlayProperties, which is deliberately not the same question -
  a player can arrive with the pack already installed, and can decline one a
  server hands out.
- "Failure modes" listed a missing Slender and a Slender in another instance,
  neither of which this effect reads any more, and had a missing FoodBar reading
  as an empty bar when it reads as a full one.
- "Tests" named assertions about the Slender that no test makes.

TunnelVisionStage.update took a parameter called `combined` for a value that is
no longer a combination of anything; it is `intensity` now, which is what every
constant around it already called it.
@github-actions

Copy link
Copy Markdown
Contributor

Test results

  264 files  264 suites   1m 47s ⏱️
  336 tests 331 ✅  5 💤 0 ❌
1 011 runs  996 ✅ 15 💤 0 ❌

Results for commit 99b8dd4.

@TheMeinerLP
TheMeinerLP force-pushed the refactor/overlay-foundation branch from af3dead to e45d219 Compare August 23, 2026 09:17
@TheMeinerLP
TheMeinerLP force-pushed the feat/survivor-tunnel-vision branch from 99b8dd4 to a001eaa Compare August 23, 2026 09:17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commands should be removed before merging

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test should be removed before merging

@theEvilReaper
theEvilReaper force-pushed the refactor/overlay-foundation branch from 0be1b7e to f2f377c Compare August 23, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants