mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-28 17:17:58 +00:00
feat(client): highlight small players with a pulsing glow (#4525)
## Description: Adds a **"Highlight small players"** client setting (in the in-game Settings menu). When it's on, human players holding **0.2% or less of the map** get a soft red glow that breathes (a pulsing aura) around their territory, starting **one minute into the game**. This makes near-eliminated players easy to spot on a busy map, even when their territory is scattered in fragments or sitting under structures. How it works: - **Purely client-side**, no simulation or determinism impact. `SmallPlayerGlowPass` renders a tile-space bloom: extract a sub-tile mask of the small players' tiles, run a separable Gaussian blur, then composite the soft aura over the map additively. It's camera-independent (no shimmer when panning/zooming), so scattered tiles blur into one clean halo. The aura breathes: its intensity fades fully to 0 at the trough for clear contrast. - The set is recomputed each tick in `WebGLFrameBuilder`: alive human players with `tilesOwned / (landTiles - fallout) <= 0.002` (0.2%), suppressed during the spawn phase and the first minute. - Backed by a persisted `UserSettings` flag, toggleable live from the in-game Settings modal (with a new, distinct icon). - Drawn after the structure passes so buildings can't hide it. - Mirrors the existing FalloutBloom pipeline and reuses the shared blur shader and render-target helpers rather than duplicating them. Tunable via `render-settings.json` (`smallPlayerGlow`: color / alpha / pulseSpeed). UI (glow fades in and out): <img width="474" height="334" alt="image" src="https://github.com/user-attachments/assets/2b94f292-2cbb-43d3-82fb-f274d1afdedf" /> <img width="976" height="400" alt="image" src="https://github.com/user-attachments/assets/f1b972d7-e046-4f7a-ab43-da1eb758c7b5" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#fefffe" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="2.5" fill="#fefffe" stroke="none" />
|
||||||
|
<circle cx="12" cy="12" r="6" />
|
||||||
|
<circle cx="12" cy="12" r="10" opacity="0.45" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
@@ -1488,6 +1488,8 @@
|
|||||||
"ground_attack_desc": "Send a ground attack to the tile under your cursor.",
|
"ground_attack_desc": "Send a ground attack to the tile under your cursor.",
|
||||||
"help_messages_desc": "Show contextual tips and warnings during gameplay, such as army limit warnings and general gameplay advice.",
|
"help_messages_desc": "Show contextual tips and warnings during gameplay, such as army limit warnings and general gameplay advice.",
|
||||||
"help_messages_label": "Help Messages",
|
"help_messages_label": "Help Messages",
|
||||||
|
"highlight_small_players_desc": "Add a glow to small players so they're easy to find.",
|
||||||
|
"highlight_small_players_label": "Highlight small players",
|
||||||
"keybind_conflict_error": "The key {key} is already bound to another action.",
|
"keybind_conflict_error": "The key {key} is already bound to another action.",
|
||||||
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
|
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
|
||||||
"left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.",
|
"left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../core/CosmeticSchemas";
|
} from "../core/CosmeticSchemas";
|
||||||
import { decodePatternData } from "../core/PatternDecoder";
|
import { decodePatternData } from "../core/PatternDecoder";
|
||||||
import { PlayerType } from "../core/game/Game";
|
import { PlayerType } from "../core/game/Game";
|
||||||
|
import { UserSettings } from "../core/game/UserSettings";
|
||||||
import { getCachedCosmetics } from "./Cosmetics";
|
import { getCachedCosmetics } from "./Cosmetics";
|
||||||
import { uploadFrameData } from "./render/frame/Upload";
|
import { uploadFrameData } from "./render/frame/Upload";
|
||||||
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
|
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
|
||||||
@@ -40,6 +41,14 @@ import type { GameView } from "./view";
|
|||||||
|
|
||||||
const PALETTE_SIZE = 4096;
|
const PALETTE_SIZE = 4096;
|
||||||
|
|
||||||
|
// A human player counts as "small" (and glows) at or below this fraction of the
|
||||||
|
// map; the glow is suppressed for a grace window after the game starts.
|
||||||
|
const SMALL_PLAYER_MAX_MAP_FRACTION = 0.002; // 0.2%
|
||||||
|
const SMALL_PLAYER_GLOW_GRACE_SECONDS = 60;
|
||||||
|
// The set is a visual aid, not tick-critical, so rescan ~once a second
|
||||||
|
// (10 ticks) instead of every tick.
|
||||||
|
const SMALL_PLAYER_GLOW_RESCAN_TICKS = 10;
|
||||||
|
|
||||||
// The effect-palette block order: index = block (rows block·MAX_TRAIL_COLORS …).
|
// The effect-palette block order: index = block (rows block·MAX_TRAIL_COLORS …).
|
||||||
// trail.frag.glsl picks its block from the trail tile's nuke bit — block 0 =
|
// trail.frag.glsl picks its block from the trail tile's nuke bit — block 0 =
|
||||||
// transportShipTrail (nuke bit 0), block 1 = nukeTrail (nuke bit 1, set by
|
// transportShipTrail (nuke bit 0), block 1 = nukeTrail (nuke bit 1, set by
|
||||||
@@ -187,12 +196,17 @@ export class WebGLFrameBuilder {
|
|||||||
this.view.refreshNames(displayNames);
|
this.view.refreshNames(displayNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly highlightSetBuf = new Uint8Array(PALETTE_SIZE);
|
||||||
|
private readonly userSettings = new UserSettings();
|
||||||
|
private glowRescanTick = 0;
|
||||||
|
|
||||||
update(gameView: GameView): void {
|
update(gameView: GameView): void {
|
||||||
this.syncPlayers(gameView);
|
this.syncPlayers(gameView);
|
||||||
this.syncPlayerEffects(gameView);
|
this.syncPlayerEffects(gameView);
|
||||||
this.syncPlayerSpawns(gameView);
|
this.syncPlayerSpawns(gameView);
|
||||||
this.syncLocalPlayer(gameView);
|
this.syncLocalPlayer(gameView);
|
||||||
this.syncSpawnOverlay(gameView);
|
this.syncSpawnOverlay(gameView);
|
||||||
|
this.syncSmallPlayerGlow(gameView);
|
||||||
this.syncTerrainDeltas(gameView);
|
this.syncTerrainDeltas(gameView);
|
||||||
this.resolveDeadUnitExplosions(gameView);
|
this.resolveDeadUnitExplosions(gameView);
|
||||||
uploadFrameData(this.view, gameView.frameData());
|
uploadFrameData(this.view, gameView.frameData());
|
||||||
@@ -328,6 +342,48 @@ export class WebGLFrameBuilder {
|
|||||||
this.view.updateSpawnOverlay(true, centers);
|
this.view.updateSpawnOverlay(true, centers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small-player glow: when the client "Highlight small players" setting is on,
|
||||||
|
* collect the alive human players holding <=0.2% of the map and push their
|
||||||
|
* smallIDs so the glow pass radiates around their territory. Skips the first
|
||||||
|
* minute of play so everyone's tiny starting territory doesn't glow.
|
||||||
|
* Client-only view — toggle it live in the settings.
|
||||||
|
*/
|
||||||
|
private syncSmallPlayerGlow(gameView: GameView): void {
|
||||||
|
if (
|
||||||
|
!this.userSettings.highlightSmallPlayers() ||
|
||||||
|
gameView.inSpawnPhase() ||
|
||||||
|
gameView.elapsedGameSeconds() < SMALL_PLAYER_GLOW_GRACE_SECONDS
|
||||||
|
) {
|
||||||
|
this.view.updateSmallPlayerGlow(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Throttle the per-player scan + upload; the glow keeps rendering the last
|
||||||
|
// set between rescans. The off/spawn/grace checks above run every tick, so
|
||||||
|
// toggling off takes effect on the next tick (deferred while the game is
|
||||||
|
// paused, since ticks stop; it clears on unpause).
|
||||||
|
if (this.glowRescanTick++ % SMALL_PLAYER_GLOW_RESCAN_TICKS !== 0) return;
|
||||||
|
// "% of the map" uses the same denominator the leaderboard/win-check use.
|
||||||
|
const denom = gameView.numLandTiles() - gameView.numTilesWithFallout();
|
||||||
|
if (denom <= 0) {
|
||||||
|
this.view.updateSmallPlayerGlow(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const set = this.highlightSetBuf;
|
||||||
|
set.fill(0);
|
||||||
|
let any = false;
|
||||||
|
for (const p of gameView.players()) {
|
||||||
|
if (!p.isPlayer() || p.type() !== PlayerType.Human || !p.isAlive()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p.numTilesOwned() / denom <= SMALL_PLAYER_MAX_MAP_FRACTION) {
|
||||||
|
set[p.smallID()] = 1;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.view.updateSmallPlayerGlow(any ? set : null);
|
||||||
|
}
|
||||||
|
|
||||||
private syncPlayers(gameView: GameView): void {
|
private syncPlayers(gameView: GameView): void {
|
||||||
if (!this.skinsInitialized) {
|
if (!this.skinsInitialized) {
|
||||||
this.skinsInitialized = true;
|
this.skinsInitialized = true;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ShowGraphicsSettingsModalEvent } from "./GraphicsSettingsModal";
|
|||||||
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
|
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
|
||||||
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
|
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
|
||||||
const exitIcon = assetUrl("images/ExitIconWhite.svg");
|
const exitIcon = assetUrl("images/ExitIconWhite.svg");
|
||||||
|
const highlightIcon = assetUrl("images/HighlightIconWhite.svg");
|
||||||
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
|
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
|
||||||
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
|
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
|
||||||
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
|
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
|
||||||
@@ -134,6 +135,11 @@ export class SettingsModal extends LitElement implements Controller {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onToggleHighlightSmallPlayersButtonClick() {
|
||||||
|
this.userSettings.toggleHighlightSmallPlayers();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
private onToggleAlertFrameButtonClick() {
|
private onToggleAlertFrameButtonClick() {
|
||||||
this.userSettings.toggleAlertFrame();
|
this.userSettings.toggleAlertFrame();
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
@@ -350,6 +356,31 @@ export class SettingsModal extends LitElement implements Controller {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||||
|
@click="${this.onToggleHighlightSmallPlayersButtonClick}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src=${highlightIcon}
|
||||||
|
alt="highlightSmallPlayers"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
${translateText("user_setting.highlight_small_players_label")}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400">
|
||||||
|
${translateText("user_setting.highlight_small_players_desc")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400">
|
||||||
|
${this.userSettings.highlightSmallPlayers()
|
||||||
|
? translateText("user_setting.on")
|
||||||
|
: translateText("user_setting.off")}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||||
@click="${this.onToggleAlertFrameButtonClick}"
|
@click="${this.onToggleAlertFrameButtonClick}"
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ export class MapRenderer {
|
|||||||
this.renderer?.updateSpawnOverlay(inSpawnPhase, centers);
|
this.renderer?.updateSpawnOverlay(inSpawnPhase, centers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set the small-player glow set (1 byte per owner smallID), or null = off. */
|
||||||
|
updateSmallPlayerGlow(set: Uint8Array | null): void {
|
||||||
|
this.renderer?.updateSmallPlayerGlow(set);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Selection box ----
|
// ---- Selection box ----
|
||||||
|
|
||||||
/** Set multiple selected units (multi-select). Pass [] to clear. */
|
/** Set multiple selected units (multi-select). Pass [] to clear. */
|
||||||
|
|||||||
@@ -380,6 +380,11 @@ export interface RenderSettings {
|
|||||||
gradientInnerEdge: number; // static gradient inner ramp end (0–1)
|
gradientInnerEdge: number; // static gradient inner ramp end (0–1)
|
||||||
gradientSolidEnd: number; // static gradient solid band end (0–1)
|
gradientSolidEnd: number; // static gradient solid band end (0–1)
|
||||||
};
|
};
|
||||||
|
smallPlayerGlow: {
|
||||||
|
color: number[]; // RGB, each 0–1
|
||||||
|
alpha: number; // peak opacity (0–1)
|
||||||
|
pulseSpeed: number; // breath animation speed
|
||||||
|
};
|
||||||
altView: {
|
altView: {
|
||||||
gridFontSize: number;
|
gridFontSize: number;
|
||||||
recolorStructures: boolean;
|
recolorStructures: boolean;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { RangeCirclePass } from "./passes/RangeCirclePass";
|
|||||||
import { SAMRadiusPass } from "./passes/SamRadiusPass";
|
import { SAMRadiusPass } from "./passes/SamRadiusPass";
|
||||||
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
|
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
|
||||||
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
|
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
|
||||||
|
import { SmallPlayerGlowPass } from "./passes/SmallPlayerGlowPass";
|
||||||
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
||||||
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
|
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
|
||||||
import { StructureLevelPass } from "./passes/StructureLevelPass";
|
import { StructureLevelPass } from "./passes/StructureLevelPass";
|
||||||
@@ -140,6 +141,7 @@ export class GPURenderer {
|
|||||||
private affiliationPalette: AffiliationPalette;
|
private affiliationPalette: AffiliationPalette;
|
||||||
private coordinateGridPass: CoordinateGridPass;
|
private coordinateGridPass: CoordinateGridPass;
|
||||||
private spawnOverlayPass: SpawnOverlayPass;
|
private spawnOverlayPass: SpawnOverlayPass;
|
||||||
|
private smallPlayerGlowPass: SmallPlayerGlowPass;
|
||||||
private inSpawnPhase = false;
|
private inSpawnPhase = false;
|
||||||
|
|
||||||
private paletteTex: WebGLTexture;
|
private paletteTex: WebGLTexture;
|
||||||
@@ -429,6 +431,14 @@ export class GPURenderer {
|
|||||||
this.settings.spawnOverlay,
|
this.settings.spawnOverlay,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.smallPlayerGlowPass = new SmallPlayerGlowPass(
|
||||||
|
gl,
|
||||||
|
mapW,
|
||||||
|
mapH,
|
||||||
|
this.res.tileTex,
|
||||||
|
this.settings.smallPlayerGlow,
|
||||||
|
);
|
||||||
|
|
||||||
// --- Trail (needs trailTex, paletteTex, effectTex) ---
|
// --- Trail (needs trailTex, paletteTex, effectTex) ---
|
||||||
this.trailPass = new TrailPass(
|
this.trailPass = new TrailPass(
|
||||||
gl,
|
gl,
|
||||||
@@ -988,6 +998,10 @@ export class GPURenderer {
|
|||||||
this.spawnOverlayPass.update(inSpawnPhase, centers);
|
this.spawnOverlayPass.update(inSpawnPhase, centers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSmallPlayerGlow(set: Uint8Array | null): void {
|
||||||
|
this.smallPlayerGlowPass.update(set);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Queries
|
// Queries
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1245,6 +1259,8 @@ export class GPURenderer {
|
|||||||
this.crosshairPass.draw(cam);
|
this.crosshairPass.draw(cam);
|
||||||
if (pe.structure) this.structurePass.draw(cam, zoom);
|
if (pe.structure) this.structurePass.draw(cam, zoom);
|
||||||
if (pe.structure) this.structureLevelPass.draw(cam, zoom);
|
if (pe.structure) this.structureLevelPass.draw(cam, zoom);
|
||||||
|
// Small-player glow draws after structures so buildings can't hide it.
|
||||||
|
this.smallPlayerGlowPass.draw(cam);
|
||||||
if (pe.bar) this.barPass.draw(cam);
|
if (pe.bar) this.barPass.draw(cam);
|
||||||
this.updateSelectionBox();
|
this.updateSelectionBox();
|
||||||
this.selectionBoxPass.draw(cam, this.frameTick);
|
this.selectionBoxPass.draw(cam, this.frameTick);
|
||||||
@@ -1293,6 +1309,7 @@ export class GPURenderer {
|
|||||||
this.affiliationPalette.dispose();
|
this.affiliationPalette.dispose();
|
||||||
this.coordinateGridPass.dispose();
|
this.coordinateGridPass.dispose();
|
||||||
this.spawnOverlayPass.dispose();
|
this.spawnOverlayPass.dispose();
|
||||||
|
this.smallPlayerGlowPass.dispose();
|
||||||
this.railroadPass.dispose();
|
this.railroadPass.dispose();
|
||||||
this.rangeCirclePass.dispose();
|
this.rangeCirclePass.dispose();
|
||||||
this.samRadiusPass.dispose();
|
this.samRadiusPass.dispose();
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ import type { HeatManager } from "../utils/HeatManager";
|
|||||||
import { TILE_DEFINES } from "../utils/TileCodec";
|
import { TILE_DEFINES } from "../utils/TileCodec";
|
||||||
|
|
||||||
import compositeFragSrc from "../shaders/fallout-bloom/composite.frag.glsl?raw";
|
import compositeFragSrc from "../shaders/fallout-bloom/composite.frag.glsl?raw";
|
||||||
import compositeVertSrc from "../shaders/fallout-bloom/composite.vert.glsl?raw";
|
|
||||||
import extractFragSrc from "../shaders/fallout-bloom/extract.frag.glsl?raw";
|
import extractFragSrc from "../shaders/fallout-bloom/extract.frag.glsl?raw";
|
||||||
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
|
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
|
||||||
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
|
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
|
||||||
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
|
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
|
||||||
|
import compositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
|
||||||
|
|
||||||
export class FalloutBloomPass {
|
export class FalloutBloomPass {
|
||||||
private gl: WebGL2RenderingContext;
|
private gl: WebGL2RenderingContext;
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import type { HeatManager } from "../utils/HeatManager";
|
|||||||
import { TILE_DEFINES } from "../utils/TileCodec";
|
import { TILE_DEFINES } from "../utils/TileCodec";
|
||||||
|
|
||||||
import falloutCompositeFragSrc from "../shaders/day-night/fallout-composite.frag.glsl?raw";
|
import falloutCompositeFragSrc from "../shaders/day-night/fallout-composite.frag.glsl?raw";
|
||||||
import falloutCompositeVertSrc from "../shaders/day-night/fallout-composite.vert.glsl?raw";
|
|
||||||
import falloutLightFragSrc from "../shaders/day-night/fallout-light.frag.glsl?raw";
|
import falloutLightFragSrc from "../shaders/day-night/fallout-light.frag.glsl?raw";
|
||||||
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
|
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
|
||||||
|
import falloutCompositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
|
||||||
|
|
||||||
export class FalloutLightPass {
|
export class FalloutLightPass {
|
||||||
private gl: WebGL2RenderingContext;
|
private gl: WebGL2RenderingContext;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* SmallPlayerGlowPass — a soft, breathing red aura around "small" players'
|
||||||
|
* territory. Tile-space bloom (extract mask -> separable blur -> additive
|
||||||
|
* composite), so it's camera-independent and cheap; mirrors FalloutBloomPass.
|
||||||
|
* A no-op unless the highlight set is non-empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RenderSettings } from "../RenderSettings";
|
||||||
|
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
|
||||||
|
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
|
||||||
|
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
|
||||||
|
import compositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
|
||||||
|
import compositeFragSrc from "../shaders/small-player-glow/composite.frag.glsl?raw";
|
||||||
|
import extractFragSrc from "../shaders/small-player-glow/extract.frag.glsl?raw";
|
||||||
|
import { getPaletteSize } from "../utils/ColorUtils";
|
||||||
|
import {
|
||||||
|
createFullscreenQuad,
|
||||||
|
createMapQuad,
|
||||||
|
createProgram,
|
||||||
|
createRenderTarget,
|
||||||
|
createTexture2D,
|
||||||
|
type RenderTarget,
|
||||||
|
shaderSrc,
|
||||||
|
toScreen,
|
||||||
|
toTarget,
|
||||||
|
} from "../utils/GlUtils";
|
||||||
|
import { TILE_DEFINES } from "../utils/TileCodec";
|
||||||
|
|
||||||
|
const SET_TEX_WIDTH = getPaletteSize(); // 1 px per owner smallID
|
||||||
|
const BLOOM_TILE_SCALE = 4; // bloom buffers run at 1/scale tile resolution
|
||||||
|
|
||||||
|
export class SmallPlayerGlowPass {
|
||||||
|
private gl: WebGL2RenderingContext;
|
||||||
|
private settings: RenderSettings["smallPlayerGlow"];
|
||||||
|
private mapW: number;
|
||||||
|
private mapH: number;
|
||||||
|
private tileTex: WebGLTexture;
|
||||||
|
|
||||||
|
private extractProg: WebGLProgram;
|
||||||
|
private blurProg: WebGLProgram;
|
||||||
|
private compositeProg: WebGLProgram;
|
||||||
|
|
||||||
|
private uExtractMapSize: WebGLUniformLocation;
|
||||||
|
private uBlurDir: WebGLUniformLocation;
|
||||||
|
private uCompositeCam: WebGLUniformLocation;
|
||||||
|
private uCompositeMapSize: WebGLUniformLocation;
|
||||||
|
private uGlowColor: WebGLUniformLocation;
|
||||||
|
private uIntensity: WebGLUniformLocation;
|
||||||
|
|
||||||
|
private setTex: WebGLTexture;
|
||||||
|
private targetA: RenderTarget;
|
||||||
|
private targetB: RenderTarget;
|
||||||
|
private mapVao: WebGLVertexArrayObject;
|
||||||
|
private quadVao: WebGLVertexArrayObject;
|
||||||
|
|
||||||
|
private active = false;
|
||||||
|
private dirty = false; // aura needs rebuilding (set changed)
|
||||||
|
private animTime = 0;
|
||||||
|
private lastTime = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
gl: WebGL2RenderingContext,
|
||||||
|
mapW: number,
|
||||||
|
mapH: number,
|
||||||
|
tileTex: WebGLTexture,
|
||||||
|
settings: RenderSettings["smallPlayerGlow"],
|
||||||
|
) {
|
||||||
|
this.gl = gl;
|
||||||
|
this.settings = settings;
|
||||||
|
this.mapW = mapW;
|
||||||
|
this.mapH = mapH;
|
||||||
|
this.tileTex = tileTex;
|
||||||
|
|
||||||
|
this.extractProg = createProgram(
|
||||||
|
gl,
|
||||||
|
fullscreenNoUvVertSrc,
|
||||||
|
shaderSrc(extractFragSrc, {
|
||||||
|
...TILE_DEFINES,
|
||||||
|
TILE_SCALE: BLOOM_TILE_SCALE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.uExtractMapSize = gl.getUniformLocation(this.extractProg, "uMapSize")!;
|
||||||
|
gl.useProgram(this.extractProg);
|
||||||
|
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uTileTex"), 0);
|
||||||
|
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uHighlightSet"), 1);
|
||||||
|
|
||||||
|
this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc);
|
||||||
|
this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!;
|
||||||
|
gl.useProgram(this.blurProg);
|
||||||
|
gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0);
|
||||||
|
|
||||||
|
this.compositeProg = createProgram(gl, compositeVertSrc, compositeFragSrc);
|
||||||
|
this.uCompositeCam = gl.getUniformLocation(this.compositeProg, "uCamera")!;
|
||||||
|
this.uCompositeMapSize = gl.getUniformLocation(
|
||||||
|
this.compositeProg,
|
||||||
|
"uMapSize",
|
||||||
|
)!;
|
||||||
|
this.uGlowColor = gl.getUniformLocation(this.compositeProg, "uGlowColor")!;
|
||||||
|
this.uIntensity = gl.getUniformLocation(this.compositeProg, "uIntensity")!;
|
||||||
|
gl.useProgram(this.compositeProg);
|
||||||
|
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uTex"), 0);
|
||||||
|
|
||||||
|
this.setTex = createTexture2D(gl, {
|
||||||
|
width: SET_TEX_WIDTH,
|
||||||
|
height: 1,
|
||||||
|
internalFormat: gl.R8UI,
|
||||||
|
format: gl.RED_INTEGER,
|
||||||
|
type: gl.UNSIGNED_BYTE,
|
||||||
|
data: null,
|
||||||
|
filter: gl.NEAREST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ceil (not floor) so a partial tile block at the map's right/bottom edge
|
||||||
|
// still gets a bloom cell — otherwise an edge player on a map whose size
|
||||||
|
// isn't a multiple of the scale gets no glow.
|
||||||
|
const bw = Math.max(1, Math.ceil(mapW / BLOOM_TILE_SCALE));
|
||||||
|
const bh = Math.max(1, Math.ceil(mapH / BLOOM_TILE_SCALE));
|
||||||
|
this.targetA = createRenderTarget(gl, bw, bh);
|
||||||
|
this.targetB = createRenderTarget(gl, bw, bh);
|
||||||
|
this.mapVao = createMapQuad(gl, mapW, mapH);
|
||||||
|
this.quadVao = createFullscreenQuad(gl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the highlight set (1 byte per owner smallID), or null to disable. */
|
||||||
|
update(set: Uint8Array | null): void {
|
||||||
|
if (set === null) {
|
||||||
|
this.active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gl = this.gl;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.setTex);
|
||||||
|
gl.texSubImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.min(set.length, SET_TEX_WIDTH),
|
||||||
|
1,
|
||||||
|
gl.RED_INTEGER,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
set,
|
||||||
|
);
|
||||||
|
this.active = true;
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(cameraMatrix: Float32Array): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
|
||||||
|
const gl = this.gl;
|
||||||
|
const s = this.settings;
|
||||||
|
const canvas = gl.canvas as HTMLCanvasElement;
|
||||||
|
const a = this.targetA;
|
||||||
|
const b = this.targetB;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
if (this.lastTime > 0) {
|
||||||
|
// Clamp the delta so a long gap (grace period, or a backgrounded tab
|
||||||
|
// pausing rAF) doesn't leap the pulse to a random phase on resume.
|
||||||
|
this.animTime += Math.min(now - this.lastTime, 100) * s.pulseSpeed;
|
||||||
|
}
|
||||||
|
this.lastTime = now;
|
||||||
|
const pulse = 0.5 + 0.5 * Math.sin(this.animTime);
|
||||||
|
|
||||||
|
// Rebuild the blurred aura only when the set changed (~1/s); its inputs
|
||||||
|
// don't move faster than that. The composite below still runs every frame.
|
||||||
|
if (this.dirty) {
|
||||||
|
gl.disable(gl.BLEND);
|
||||||
|
|
||||||
|
// Extract the small-player mask at sub-tile resolution.
|
||||||
|
gl.useProgram(this.extractProg);
|
||||||
|
gl.uniform2f(this.uExtractMapSize, this.mapW, this.mapH);
|
||||||
|
toTarget(gl, a, () => {
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
|
||||||
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.setTex);
|
||||||
|
gl.bindVertexArray(this.quadVao);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separable blur: horizontal into B, vertical back into A.
|
||||||
|
gl.useProgram(this.blurProg);
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
toTarget(gl, b, () => {
|
||||||
|
gl.uniform2f(this.uBlurDir, 1 / a.w, 0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, a.tex);
|
||||||
|
gl.bindVertexArray(this.quadVao);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
});
|
||||||
|
toTarget(gl, a, () => {
|
||||||
|
gl.uniform2f(this.uBlurDir, 0, 1 / b.h);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, b.tex);
|
||||||
|
gl.bindVertexArray(this.quadVao);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
});
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite the cached aura over the map every frame. Premultiplied-over
|
||||||
|
// (not pure additive) so the glow keeps its color over bright terrain
|
||||||
|
// instead of washing out to white.
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
toScreen(gl, canvas.width, canvas.height, () => {
|
||||||
|
gl.useProgram(this.compositeProg);
|
||||||
|
gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix);
|
||||||
|
gl.uniform2f(this.uCompositeMapSize, this.mapW, this.mapH);
|
||||||
|
gl.uniform3fv(this.uGlowColor, s.color);
|
||||||
|
gl.uniform1f(this.uIntensity, s.alpha * pulse);
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, a.tex);
|
||||||
|
gl.bindVertexArray(this.mapVao);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
});
|
||||||
|
gl.bindVertexArray(null);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // restore overlay default
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
const gl = this.gl;
|
||||||
|
gl.deleteProgram(this.extractProg);
|
||||||
|
gl.deleteProgram(this.blurProg);
|
||||||
|
gl.deleteProgram(this.compositeProg);
|
||||||
|
gl.deleteTexture(this.setTex);
|
||||||
|
gl.deleteTexture(this.targetA.tex);
|
||||||
|
gl.deleteTexture(this.targetB.tex);
|
||||||
|
gl.deleteFramebuffer(this.targetA.fbo);
|
||||||
|
gl.deleteFramebuffer(this.targetB.fbo);
|
||||||
|
gl.deleteVertexArray(this.mapVao);
|
||||||
|
gl.deleteVertexArray(this.quadVao);
|
||||||
|
// tileTex owned by GPUResources
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -331,6 +331,11 @@
|
|||||||
"gradientInnerEdge": 0.01,
|
"gradientInnerEdge": 0.01,
|
||||||
"gradientSolidEnd": 0.1
|
"gradientSolidEnd": 0.1
|
||||||
},
|
},
|
||||||
|
"smallPlayerGlow": {
|
||||||
|
"color": [1.0, 0.12, 0.1],
|
||||||
|
"alpha": 0.9,
|
||||||
|
"pulseSpeed": 0.003
|
||||||
|
},
|
||||||
"altView": {
|
"altView": {
|
||||||
"gridFontSize": 24,
|
"gridFontSize": 24,
|
||||||
"recolorStructures": true
|
"recolorStructures": true
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
#version 300 es
|
|
||||||
precision highp float;
|
|
||||||
layout(location = 0) in vec2 aPos;
|
|
||||||
uniform mat3 uCamera;
|
|
||||||
uniform vec2 uMapSize;
|
|
||||||
out vec2 vUV;
|
|
||||||
void main() {
|
|
||||||
vec3 clip = uCamera * vec3(aPos, 1.0);
|
|
||||||
gl_Position = vec4(clip.xy, 0.0, 1.0);
|
|
||||||
vUV = aPos / uMapSize;
|
|
||||||
}
|
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
#version 300 es
|
#version 300 es
|
||||||
precision highp float;
|
precision highp float;
|
||||||
|
// Camera-projected map quad -> tile-space UV. Shared by the bloom/glow
|
||||||
|
// composites so they sample an offscreen map-sized texture in world space.
|
||||||
layout(location = 0) in vec2 aPos;
|
layout(location = 0) in vec2 aPos;
|
||||||
uniform mat3 uCamera;
|
uniform mat3 uCamera;
|
||||||
uniform vec2 uMapSize;
|
uniform vec2 uMapSize;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
// Tints the blurred aura with the glow color and the breathing intensity
|
||||||
|
// (alpha * pulse), premultiplied for an additive composite over the map.
|
||||||
|
uniform sampler2D uTex;
|
||||||
|
uniform vec3 uGlowColor;
|
||||||
|
uniform float uIntensity; // glow alpha * pulse (0 = invisible)
|
||||||
|
in vec2 vUV;
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float g = texture(uTex, vUV).r;
|
||||||
|
float a = clamp(g * uIntensity, 0.0, 1.0);
|
||||||
|
fragColor = vec4(uGlowColor * a, a); // premultiplied
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
precision highp usampler2D;
|
||||||
|
|
||||||
|
// Emit 1 where a "small" player owns a tile. Territory is sparse (often lone
|
||||||
|
// tiles), so each cell scans its whole TILE_SCALE block instead of point-
|
||||||
|
// sampling, else single tiles get missed. OWNER_MASK/TILE_SCALE are injected.
|
||||||
|
|
||||||
|
uniform usampler2D uTileTex; // R16UI — tile state (owner in low bits)
|
||||||
|
uniform usampler2D uHighlightSet; // R8UI, 1px per owner — 1 = highlighted
|
||||||
|
uniform vec2 uMapSize;
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
ivec2 base = ivec2(gl_FragCoord.xy) * TILE_SCALE;
|
||||||
|
for (int j = 0; j < TILE_SCALE; j++) {
|
||||||
|
for (int i = 0; i < TILE_SCALE; i++) {
|
||||||
|
ivec2 tc = base + ivec2(i, j);
|
||||||
|
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) continue;
|
||||||
|
uint owner = texelFetch(uTileTex, tc, 0).r & uint(OWNER_MASK);
|
||||||
|
if (owner != 0u &&
|
||||||
|
texelFetch(uHighlightSet, ivec2(int(owner), 0), 0).r > 0u) {
|
||||||
|
fragColor = vec4(1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
discard;
|
||||||
|
}
|
||||||
@@ -175,6 +175,37 @@ export function toScreen(
|
|||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an RGBA8 render target (texture + FBO), LINEAR-filtered and clamped.
|
||||||
|
* For offscreen passes that ping-pong (blur, bloom).
|
||||||
|
*/
|
||||||
|
export function createRenderTarget(
|
||||||
|
gl: WebGL2RenderingContext,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): RenderTarget {
|
||||||
|
const tex = createTexture2D(gl, {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
internalFormat: gl.RGBA8,
|
||||||
|
format: gl.RGBA,
|
||||||
|
type: gl.UNSIGNED_BYTE,
|
||||||
|
data: null,
|
||||||
|
filter: gl.LINEAR,
|
||||||
|
});
|
||||||
|
const fbo = gl.createFramebuffer()!;
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.framebufferTexture2D(
|
||||||
|
gl.FRAMEBUFFER,
|
||||||
|
gl.COLOR_ATTACHMENT0,
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
tex,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
return { fbo, tex, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
export function createMapQuad(
|
export function createMapQuad(
|
||||||
gl: WebGL2RenderingContext,
|
gl: WebGL2RenderingContext,
|
||||||
mapWidth: number,
|
mapWidth: number,
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ export class UserSettings {
|
|||||||
return this.getBool("settings.emojis", true);
|
return this.getBool("settings.emojis", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
highlightSmallPlayers() {
|
||||||
|
return this.getBool("settings.highlightSmallPlayers", false);
|
||||||
|
}
|
||||||
|
|
||||||
performanceOverlay() {
|
performanceOverlay() {
|
||||||
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
|
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
|
||||||
}
|
}
|
||||||
@@ -191,6 +195,13 @@ export class UserSettings {
|
|||||||
this.setBool("settings.emojis", !this.emojis());
|
this.setBool("settings.emojis", !this.emojis());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleHighlightSmallPlayers() {
|
||||||
|
this.setBool(
|
||||||
|
"settings.highlightSmallPlayers",
|
||||||
|
!this.highlightSmallPlayers(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Performance overlay specifically needs a direct setter for Shift-D
|
// Performance overlay specifically needs a direct setter for Shift-D
|
||||||
setPerformanceOverlay(value: boolean) {
|
setPerformanceOverlay(value: boolean) {
|
||||||
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
|
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
|
||||||
|
|||||||
@@ -58,3 +58,31 @@ describe("UserSettings effect selection", () => {
|
|||||||
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
|
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UserSettings highlight small players", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
(
|
||||||
|
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||||
|
).cache.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to off", () => {
|
||||||
|
expect(new UserSettings().highlightSmallPlayers()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles on and off", () => {
|
||||||
|
const s = new UserSettings();
|
||||||
|
s.toggleHighlightSmallPlayers();
|
||||||
|
expect(s.highlightSmallPlayers()).toBe(true);
|
||||||
|
s.toggleHighlightSmallPlayers();
|
||||||
|
expect(s.highlightSmallPlayers()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares state across instances via the static cache", () => {
|
||||||
|
// The settings modal and the renderer's frame builder each hold their own
|
||||||
|
// UserSettings; a toggle in one must be visible to the other.
|
||||||
|
new UserSettings().toggleHighlightSmallPlayers();
|
||||||
|
expect(new UserSettings().highlightSmallPlayers()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user