mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-09 17:05:42 +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.",
|
||||
"help_messages_desc": "Show contextual tips and warnings during gameplay, such as army limit warnings and general gameplay advice.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import { PlayerType } from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { getCachedCosmetics } from "./Cosmetics";
|
||||
import { uploadFrameData } from "./render/frame/Upload";
|
||||
// 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;
|
||||
|
||||
// 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 …).
|
||||
// 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
|
||||
@@ -187,12 +196,17 @@ export class WebGLFrameBuilder {
|
||||
this.view.refreshNames(displayNames);
|
||||
}
|
||||
|
||||
private readonly highlightSetBuf = new Uint8Array(PALETTE_SIZE);
|
||||
private readonly userSettings = new UserSettings();
|
||||
private glowRescanTick = 0;
|
||||
|
||||
update(gameView: GameView): void {
|
||||
this.syncPlayers(gameView);
|
||||
this.syncPlayerEffects(gameView);
|
||||
this.syncPlayerSpawns(gameView);
|
||||
this.syncLocalPlayer(gameView);
|
||||
this.syncSpawnOverlay(gameView);
|
||||
this.syncSmallPlayerGlow(gameView);
|
||||
this.syncTerrainDeltas(gameView);
|
||||
this.resolveDeadUnitExplosions(gameView);
|
||||
uploadFrameData(this.view, gameView.frameData());
|
||||
@@ -328,6 +342,48 @@ export class WebGLFrameBuilder {
|
||||
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 {
|
||||
if (!this.skinsInitialized) {
|
||||
this.skinsInitialized = true;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ShowGraphicsSettingsModalEvent } from "./GraphicsSettingsModal";
|
||||
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
|
||||
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
|
||||
const exitIcon = assetUrl("images/ExitIconWhite.svg");
|
||||
const highlightIcon = assetUrl("images/HighlightIconWhite.svg");
|
||||
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
|
||||
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
|
||||
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
|
||||
@@ -134,6 +135,11 @@ export class SettingsModal extends LitElement implements Controller {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleHighlightSmallPlayersButtonClick() {
|
||||
this.userSettings.toggleHighlightSmallPlayers();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleAlertFrameButtonClick() {
|
||||
this.userSettings.toggleAlertFrame();
|
||||
this.requestUpdate();
|
||||
@@ -350,6 +356,31 @@ export class SettingsModal extends LitElement implements Controller {
|
||||
</div>
|
||||
</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
|
||||
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}"
|
||||
|
||||
@@ -239,6 +239,11 @@ export class MapRenderer {
|
||||
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 ----
|
||||
|
||||
/** 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)
|
||||
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: {
|
||||
gridFontSize: number;
|
||||
recolorStructures: boolean;
|
||||
|
||||
@@ -48,6 +48,7 @@ import { RangeCirclePass } from "./passes/RangeCirclePass";
|
||||
import { SAMRadiusPass } from "./passes/SamRadiusPass";
|
||||
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
|
||||
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
|
||||
import { SmallPlayerGlowPass } from "./passes/SmallPlayerGlowPass";
|
||||
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
||||
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
|
||||
import { StructureLevelPass } from "./passes/StructureLevelPass";
|
||||
@@ -140,6 +141,7 @@ export class GPURenderer {
|
||||
private affiliationPalette: AffiliationPalette;
|
||||
private coordinateGridPass: CoordinateGridPass;
|
||||
private spawnOverlayPass: SpawnOverlayPass;
|
||||
private smallPlayerGlowPass: SmallPlayerGlowPass;
|
||||
private inSpawnPhase = false;
|
||||
|
||||
private paletteTex: WebGLTexture;
|
||||
@@ -429,6 +431,14 @@ export class GPURenderer {
|
||||
this.settings.spawnOverlay,
|
||||
);
|
||||
|
||||
this.smallPlayerGlowPass = new SmallPlayerGlowPass(
|
||||
gl,
|
||||
mapW,
|
||||
mapH,
|
||||
this.res.tileTex,
|
||||
this.settings.smallPlayerGlow,
|
||||
);
|
||||
|
||||
// --- Trail (needs trailTex, paletteTex, effectTex) ---
|
||||
this.trailPass = new TrailPass(
|
||||
gl,
|
||||
@@ -988,6 +998,10 @@ export class GPURenderer {
|
||||
this.spawnOverlayPass.update(inSpawnPhase, centers);
|
||||
}
|
||||
|
||||
updateSmallPlayerGlow(set: Uint8Array | null): void {
|
||||
this.smallPlayerGlowPass.update(set);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1245,6 +1259,8 @@ export class GPURenderer {
|
||||
this.crosshairPass.draw(cam);
|
||||
if (pe.structure) this.structurePass.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);
|
||||
this.updateSelectionBox();
|
||||
this.selectionBoxPass.draw(cam, this.frameTick);
|
||||
@@ -1293,6 +1309,7 @@ export class GPURenderer {
|
||||
this.affiliationPalette.dispose();
|
||||
this.coordinateGridPass.dispose();
|
||||
this.spawnOverlayPass.dispose();
|
||||
this.smallPlayerGlowPass.dispose();
|
||||
this.railroadPass.dispose();
|
||||
this.rangeCirclePass.dispose();
|
||||
this.samRadiusPass.dispose();
|
||||
|
||||
@@ -27,11 +27,11 @@ import type { HeatManager } from "../utils/HeatManager";
|
||||
import { TILE_DEFINES } from "../utils/TileCodec";
|
||||
|
||||
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 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";
|
||||
|
||||
export class FalloutBloomPass {
|
||||
private gl: WebGL2RenderingContext;
|
||||
|
||||
@@ -24,9 +24,9 @@ import type { HeatManager } from "../utils/HeatManager";
|
||||
import { TILE_DEFINES } from "../utils/TileCodec";
|
||||
|
||||
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 fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
|
||||
import falloutCompositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
|
||||
|
||||
export class FalloutLightPass {
|
||||
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,
|
||||
"gradientSolidEnd": 0.1
|
||||
},
|
||||
"smallPlayerGlow": {
|
||||
"color": [1.0, 0.12, 0.1],
|
||||
"alpha": 0.9,
|
||||
"pulseSpeed": 0.003
|
||||
},
|
||||
"altView": {
|
||||
"gridFontSize": 24,
|
||||
"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;
|
||||
}
|
||||
+13
-11
@@ -1,11 +1,13 @@
|
||||
#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;
|
||||
}
|
||||
#version 300 es
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
gl: WebGL2RenderingContext,
|
||||
mapWidth: number,
|
||||
|
||||
@@ -139,6 +139,10 @@ export class UserSettings {
|
||||
return this.getBool("settings.emojis", true);
|
||||
}
|
||||
|
||||
highlightSmallPlayers() {
|
||||
return this.getBool("settings.highlightSmallPlayers", false);
|
||||
}
|
||||
|
||||
performanceOverlay() {
|
||||
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
|
||||
}
|
||||
@@ -191,6 +195,13 @@ export class UserSettings {
|
||||
this.setBool("settings.emojis", !this.emojis());
|
||||
}
|
||||
|
||||
toggleHighlightSmallPlayers() {
|
||||
this.setBool(
|
||||
"settings.highlightSmallPlayers",
|
||||
!this.highlightSmallPlayers(),
|
||||
);
|
||||
}
|
||||
|
||||
// Performance overlay specifically needs a direct setter for Shift-D
|
||||
setPerformanceOverlay(value: boolean) {
|
||||
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
|
||||
|
||||
@@ -58,3 +58,31 @@ describe("UserSettings effect selection", () => {
|
||||
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