diff --git a/resources/images/HighlightIconWhite.svg b/resources/images/HighlightIconWhite.svg new file mode 100644 index 000000000..607277ee2 --- /dev/null +++ b/resources/images/HighlightIconWhite.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 852b4c33a..4e1d7b3d3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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.", diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index e6030ce5a..cf569a3db 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -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; diff --git a/src/client/hud/layers/SettingsModal.ts b/src/client/hud/layers/SettingsModal.ts index 14c40e79a..6b905f63a 100644 --- a/src/client/hud/layers/SettingsModal.ts +++ b/src/client/hud/layers/SettingsModal.ts @@ -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 { + +