mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:35:15 +00:00
feat(client): verified-name toggle (play under your account name) (#4648)
## Summary The client half of the verified-name plan: subscribers with a claimed bare name can opt in to play under it, and the game renders a **server-validated** blue verified check next to their name. ### Verified toggle (username row) - Blue check-circle badge + "Verified" label act as a toggle button in the play username row, shown to all users (hidden on CrazyGames via `no-crazygames`); both turn blue when active and the input locks to the bare account name — `getUsername()` feeds every join path. - Non-subscribers (logged out, `unclaimed`, lapsed `claimed`) get a subscribe-first dialog whose **View store** routes to `#modal=store&tab=subscriptions`. Entitled players without a usable name (never set, or `TEMPORARY####`) are routed to the account modal instead. - The opt-in persists in localStorage but never auto-enables while ineligible; unchecking restores the saved free-form name. Anonymity stays a first-class option. - After a successful username save the page reloads so every consumer restarts from a fresh `/users/@me`. ### In-game badge (GL name pass) - New `verified` boolean on `PlayerCosmeticRefsSchema` (client claim) and `PlayerCosmeticsSchema` (resolved). `getPlayerCosmeticsRefs()` sets it from the toggle state, covering both the multiplayer join and locally-resolved singleplayer paths. - **Server-validated at join, today**: the Worker already fetches `/users/@me` with the client's token on every authenticated join (flares/friends/clans), and that response carries the account username since #4644 — so `verifiedBadgeAllowed` keeps the claim only when the bare-name status is `premium`/`indefinite` AND the join name exactly matches the account's resolved display name. Zero extra requests, no token-claim staleness. Mismatches strip the badge rather than rejecting the join; a pre-start rejoin identity change also drops it (that path skips join-time validation). Anonymous persistent-ID joins exist only in Dev and keep the claim for local testing. - Rendering: 10th instanced slot in the name pass's `StatusIconProgram`, anchored just right of the name text (`nameHalfWidth` was already in the player data texture), slightly below the name line's center. The badge art is a new cell (index 11) in `status-atlas.png`; the flag rides the free `pd8.y` column. Anonymized viewers never see it (cosmetics are already stripped for hidden players). ## Test plan - Full suite passes (2,047 + 173), including new tests: cosmetics schema `verified` (optional/boolean-only), `Privilege.isAllowed` pass-through, and `verifiedBadgeAllowed` (exact match, case rejection, unentitled statuses, missing name). - Headless real-app verification of every toggle state (dialogs, persistence, silent drops, store-tab routing, save→reload) with stubbed API routes. - Drove a real singleplayer game headless (WebGL via ANGLE Metal): the blue check renders to the right of "Bob", scaled and tucked to the name; bot/nation names show no badge. Screenshot-verified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -465,10 +465,7 @@ export class AccountModal extends BaseModal {
|
||||
private renderUsernamePanel(): TemplateResult | "" {
|
||||
const player = this.userMeResponse?.player;
|
||||
if (!player || player.usernameStatus === undefined) return "";
|
||||
return html`<username-panel
|
||||
.player=${player}
|
||||
@username-changed=${() => this.requestUpdate()}
|
||||
></username-panel>`;
|
||||
return html`<username-panel .player=${player}></username-panel>`;
|
||||
}
|
||||
|
||||
private renderRewardsPanel(): TemplateResult | "" {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
purchaseWithCurrency,
|
||||
} from "./Api";
|
||||
import { showInGameAlert, showInGameConfirm } from "./InGameModal";
|
||||
import { isPlayingVerified } from "./UsernameInput";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
|
||||
@@ -759,6 +760,7 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
skinName,
|
||||
crownName,
|
||||
effects: Object.keys(effects).length > 0 ? effects : undefined,
|
||||
verified: isPlayingVerified() ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -823,6 +825,10 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
if (Object.keys(effects).length > 0) result.effects = effects;
|
||||
}
|
||||
|
||||
if (refs.verified) {
|
||||
result.verified = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+146
-3
@@ -1,7 +1,8 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { ANON_ANIMALS, anonAnimalName } from "../core/AnonAnimals";
|
||||
import { isTemporaryUsername, UserMeResponse } from "../core/ApiSchemas";
|
||||
import { sanitizeClanTag } from "../core/Util";
|
||||
import {
|
||||
MAX_CLAN_TAG_LENGTH,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "../core/validations/username";
|
||||
import { checkClanTagOwnership } from "./ClanApi";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { showInGameConfirm } from "./InGameModal";
|
||||
|
||||
interface LangSelectorLike {
|
||||
currentLang?: string;
|
||||
@@ -22,11 +24,16 @@ interface LangSelectorLike {
|
||||
|
||||
const usernameKey: string = "username";
|
||||
const clanTagKey: string = "clanTag";
|
||||
const useVerifiedNameKey: string = "useVerifiedName";
|
||||
|
||||
@customElement("username-input")
|
||||
export class UsernameInput extends LitElement {
|
||||
@state() private baseUsername: string = "";
|
||||
@state() private clanTag: string = "";
|
||||
// Playing under the account's verified bare name (sub-only). The free-form
|
||||
// name stays in baseUsername/localStorage so unchecking restores it.
|
||||
@state() private verifiedActive: boolean = false;
|
||||
private userMe: UserMeResponse | false | null = null;
|
||||
|
||||
// Clans aren't supported on CrazyGames — hide the tag input and never submit one.
|
||||
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
|
||||
@@ -51,10 +58,88 @@ export class UsernameInput extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Account state for the verified-name toggle. Same document-level pattern
|
||||
// as AccountModal; Main dispatches this after auth resolves and on
|
||||
// CrazyGames sign-in.
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
this.userMe = (event as CustomEvent).detail as UserMeResponse | false;
|
||||
this.applyVerifiedPreference();
|
||||
});
|
||||
}
|
||||
|
||||
// The server-resolved bare name this player may play verified under, or null
|
||||
// when ineligible. Sub-only by design: `claimed` (lapsed) holders and
|
||||
// TEMPORARY####-renamed players don't qualify.
|
||||
private verifiedName(): string | null {
|
||||
if (this.userMe === null || this.userMe === false) return null;
|
||||
const player = this.userMe.player;
|
||||
const status = player.usernameStatus;
|
||||
if (status !== "premium" && status !== "indefinite") return null;
|
||||
if (!player.username || isTemporaryUsername(player.usernameBase)) {
|
||||
return null;
|
||||
}
|
||||
return player.username;
|
||||
}
|
||||
|
||||
// Turn the toggle on iff the player opted in previously AND is still
|
||||
// eligible; silently off otherwise (logout, lapsed sub, TEMPORARY rename).
|
||||
// Never auto-enables without a stored opt-in — players who want to stay
|
||||
// anonymous must be able to play under an unrelated name.
|
||||
private applyVerifiedPreference() {
|
||||
this.verifiedActive =
|
||||
!this.onCrazyGames &&
|
||||
localStorage.getItem(useVerifiedNameKey) === "true" &&
|
||||
this.verifiedName() !== null;
|
||||
this.requestUpdate();
|
||||
this.validateAndStore();
|
||||
}
|
||||
|
||||
private async handleVerifiedToggle() {
|
||||
// verifiedActive implies eligible (applyVerifiedPreference), so this
|
||||
// covers both turning off and an eligible turn-on.
|
||||
if (this.verifiedActive || this.verifiedName() !== null) {
|
||||
this.verifiedActive = !this.verifiedActive;
|
||||
localStorage.setItem(useVerifiedNameKey, String(this.verifiedActive));
|
||||
this.validateAndStore();
|
||||
return;
|
||||
}
|
||||
// Ineligible — the toggle can't turn on.
|
||||
const player = this.userMe === false ? undefined : this.userMe?.player;
|
||||
const status = player?.usernameStatus;
|
||||
if (status === "premium" || status === "indefinite") {
|
||||
// Subscribed but no usable name yet (never set, or TEMPORARY####):
|
||||
// send them to the account modal to pick one.
|
||||
window.location.hash = "modal=account";
|
||||
return;
|
||||
}
|
||||
const goStore = await showInGameConfirm(
|
||||
translateText("username.verified_sub_required"),
|
||||
{
|
||||
heading: translateText("username.verified_heading"),
|
||||
variant: "warning",
|
||||
confirmText: translateText("username.verified_sub_required_confirm"),
|
||||
},
|
||||
);
|
||||
if (goStore) {
|
||||
window.location.hash = "modal=store&tab=subscriptions";
|
||||
}
|
||||
}
|
||||
|
||||
public getUsername(): string {
|
||||
if (this.verifiedActive) {
|
||||
const verified = this.verifiedName();
|
||||
if (verified !== null) return verified;
|
||||
}
|
||||
return this.baseUsername.trim();
|
||||
}
|
||||
|
||||
/** True when the player is playing under their verified account name. */
|
||||
public isVerified(): boolean {
|
||||
return this.verifiedActive && this.verifiedName() !== null;
|
||||
}
|
||||
|
||||
public getClanTag(): string | null {
|
||||
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
|
||||
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
|
||||
@@ -172,13 +257,51 @@ export class UsernameInput extends LitElement {
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.baseUsername}
|
||||
.value=${this.verifiedActive
|
||||
? (this.verifiedName() ?? "")
|
||||
: this.baseUsername}
|
||||
@input=${this.handleUsernameChange}
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
minlength="${MIN_USERNAME_LENGTH}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
?disabled=${this.verifiedActive}
|
||||
title=${this.verifiedActive
|
||||
? translateText("username.verified_heading")
|
||||
: ""}
|
||||
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent disabled:text-blue-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="no-crazygames group flex items-center gap-1.5 shrink-0 cursor-pointer select-none"
|
||||
title=${translateText("username.verified_heading")}
|
||||
aria-pressed=${this.verifiedActive ? "true" : "false"}
|
||||
@click=${this.handleVerifiedToggle}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5 transition-colors ${this.verifiedActive
|
||||
? "text-blue-400"
|
||||
: "text-white/30 group-hover:text-white/50"}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
|
||||
<path
|
||||
d="M7.5 12.5l3 3 6-6.5"
|
||||
stroke="white"
|
||||
stroke-width="2.2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
class="hidden sm:inline text-sm font-medium transition-colors ${this
|
||||
.verifiedActive
|
||||
? "text-blue-400"
|
||||
: "text-white/70 group-hover:text-white"}"
|
||||
>${translateText("username.verified_toggle")}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
${this.validationError
|
||||
? html`<div
|
||||
@@ -286,6 +409,19 @@ export class UsernameInput extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Playing under the verified account name: it's server-issued, so skip
|
||||
// free-form validation and leave the stored free-form name untouched for
|
||||
// when the toggle turns off.
|
||||
if (this.verifiedActive) {
|
||||
this._isValid = true;
|
||||
this.validationError = "";
|
||||
if (!this.onCrazyGames) {
|
||||
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
|
||||
}
|
||||
this.emitValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validateUsername(trimmedBase);
|
||||
this._isValid = result.isValid;
|
||||
if (result.isValid) {
|
||||
@@ -319,6 +455,13 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the player is currently playing under their verified account name.
|
||||
// For join paths that can't reach the component instance (Cosmetics refs).
|
||||
export function isPlayingVerified(): boolean {
|
||||
const el = document.querySelector<UsernameInput>("username-input");
|
||||
return el?.isVerified() ?? false;
|
||||
}
|
||||
|
||||
// A memorable anonymous username: "Anon" + animal (+ digit), the same handle
|
||||
// format the server-side anonymisation overlay uses (anonAnimalName). Client-side
|
||||
// fallback for players who never set a name — no roster here, so it draws a
|
||||
|
||||
@@ -442,6 +442,7 @@ export class WebGLFrameBuilder {
|
||||
displayName: p.displayName(),
|
||||
flag: flagUrl,
|
||||
crown: crownUrl,
|
||||
verified: p.cosmetics.verified === true,
|
||||
color: p.territoryColor().toHex(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class LobbyTeamView extends LitElement {
|
||||
? "bg-malibu-blue/20 border-sky-500/40"
|
||||
: "bg-gray-700/70 border-transparent"}"
|
||||
>
|
||||
${displayName}
|
||||
${displayName} ${this.renderVerifiedBadge(client)}
|
||||
</div>`;
|
||||
},
|
||||
)}
|
||||
@@ -196,7 +196,9 @@ export class LobbyTeamView extends LitElement {
|
||||
? "current-player"
|
||||
: ""}"
|
||||
>
|
||||
<span class="text-white">${displayName}</span>
|
||||
<span class="text-white"
|
||||
>${displayName} ${this.renderVerifiedBadge(client)}</span
|
||||
>
|
||||
${this.renderRevealToggle(client.clientID)}
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
@@ -266,7 +268,9 @@ export class LobbyTeamView extends LitElement {
|
||||
? "bg-malibu-blue/20 border-sky-500/40"
|
||||
: "bg-gray-700/70 border-transparent"}"
|
||||
>
|
||||
<span class="truncate text-white">${displayName}</span>
|
||||
<span class="truncate text-white"
|
||||
>${displayName} ${this.renderVerifiedBadge(p)}</span
|
||||
>
|
||||
${this.renderRevealToggle(p.clientID)}
|
||||
${p.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="ml-2 text-[11px] text-green-300"
|
||||
@@ -424,4 +428,28 @@ export class LobbyTeamView extends LitElement {
|
||||
createRandomName(client.username, PlayerType.Human) ?? client.username;
|
||||
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
|
||||
}
|
||||
|
||||
// Blue check for players on their server-validated account name. Withheld
|
||||
// when this row's name is locally anonymized — the badge vouches for the
|
||||
// exact displayed name.
|
||||
private renderVerifiedBadge(client: ClientInfo) {
|
||||
const anonymized =
|
||||
this.userSettings.anonymousNames() && !this.isCurrentPlayer(client);
|
||||
if (client.verified !== true || anonymized) return html``;
|
||||
return html`<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block w-3.5 h-3.5 align-[-2px] text-blue-400 shrink-0"
|
||||
aria-label=${translateText("username.verified_heading")}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
|
||||
<path
|
||||
d="M7.5 12.5l3 3 6-6.5"
|
||||
stroke="white"
|
||||
stroke-width="2.2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,44 +115,16 @@ export class UsernamePanel extends LitElement {
|
||||
|
||||
this.busy = true;
|
||||
const result = await updateUsername(name);
|
||||
this.busy = false;
|
||||
|
||||
if (result.ok) {
|
||||
// The panel and AccountModal share the same player object, so updating
|
||||
// it here keeps every consumer consistent; the event just triggers the
|
||||
// parent re-render.
|
||||
this.player.username = result.data.username;
|
||||
this.player.usernameBase = result.data.base;
|
||||
this.player.usernameDiscriminator = result.data.discriminator;
|
||||
this.player.usernameStatus = result.data.usernameStatus;
|
||||
this.player.nextUsernameChangeAt = result.data.nextUsernameChangeAt;
|
||||
// A rename either kept the claim (premium/indefinite) or abandoned it
|
||||
// (unclaimed) — either way no grace deadline remains.
|
||||
this.player.usernameClaimExpiresAt = null;
|
||||
this.draft = result.data.base;
|
||||
this.error = "";
|
||||
this.requestUpdate();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("account_modal.username_changed", {
|
||||
name: result.data.username,
|
||||
}),
|
||||
color: "green",
|
||||
duration: 4000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("username-changed", {
|
||||
detail: result.data,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.error = this.errorMessage(result);
|
||||
// Reload so every consumer starts from a fresh /users/@me; the account
|
||||
// modal reopens via #modal=account showing the new name. Keep the form
|
||||
// locked (busy) while the reload happens.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this.busy = false;
|
||||
this.error = this.errorMessage(result);
|
||||
}
|
||||
|
||||
private errorMessage(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* StatusIconProgram — instanced status icons above player names.
|
||||
*
|
||||
* Renders up to 9 status icons per player (crown, traitor, disconnected,
|
||||
* alliance, alliance request, target, embargo, nuke, doomsday-clock skull). Each
|
||||
* instance reads individual float flags to decide whether to draw.
|
||||
* Renders up to 10 icons per player: 9 status-row icons above the name
|
||||
* (crown, traitor, disconnected, alliance, alliance request, target, embargo,
|
||||
* nuke, doomsday-clock skull) plus the verified badge to the right of the
|
||||
* name. Each instance reads individual float flags to decide whether to draw.
|
||||
*
|
||||
* Owns: shader program, uniform locations, status atlas texture.
|
||||
* The shared playerDataTex is passed in but not owned/deleted.
|
||||
@@ -20,7 +21,7 @@ import type { ParsedAtlas } from "./Types";
|
||||
|
||||
const statusAtlasUrl = assetUrl("atlases/status-atlas.png");
|
||||
|
||||
const MAX_STATUS_ICONS = 9;
|
||||
const MAX_STATUS_ICONS = 10;
|
||||
|
||||
export class StatusIconProgram {
|
||||
private gl: WebGL2RenderingContext;
|
||||
|
||||
@@ -667,9 +667,9 @@ export class NamePass {
|
||||
d[off + 30] = slot.allianceFraction;
|
||||
d[off + 31] = slot.allianceRemainingTicks;
|
||||
|
||||
// Column 8: crownLayerIdx (crown cosmetic), rest free
|
||||
// Column 8: crownLayerIdx (crown cosmetic), verified badge, rest free
|
||||
d[off + 32] = slot.crownLayerIdx;
|
||||
d[off + 33] = 0.0;
|
||||
d[off + 33] = slot.static.verified === true ? 1.0 : 0.0;
|
||||
d[off + 34] = 0.0;
|
||||
d[off + 35] = 0.0;
|
||||
|
||||
|
||||
@@ -88,16 +88,19 @@ vec4 cellUV(int idx) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Decode instance ID → playerIdx + iconSlot (0..8)
|
||||
int playerIdx = gl_InstanceID / 9;
|
||||
int iconSlot = gl_InstanceID - playerIdx * 9;
|
||||
// Decode instance ID → playerIdx + iconSlot (0..8 status row, 9 = verified
|
||||
// badge to the right of the name)
|
||||
int playerIdx = gl_InstanceID / 10;
|
||||
int iconSlot = gl_InstanceID - playerIdx * 10;
|
||||
bool isVerifiedSlot = (iconSlot == 9);
|
||||
|
||||
// Read player data
|
||||
vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime
|
||||
vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive
|
||||
vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, nameShade, nameHalfWidth
|
||||
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, smallID, [free]
|
||||
vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks
|
||||
vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, [free]
|
||||
vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, verified, [free]
|
||||
|
||||
// A crown cosmetic skins the first-place crown (slot 0).
|
||||
vCrownLayer = (iconSlot == 0 && pd8.x >= 0.0) ? int(pd8.x) : -1;
|
||||
@@ -120,8 +123,10 @@ void main() {
|
||||
// Read status flags into array
|
||||
readStatusFlags(playerIdx);
|
||||
|
||||
// Early out: this icon slot is inactive
|
||||
if (statusFlag[iconSlot] < 0.5) {
|
||||
// Early out: this icon slot is inactive. The verified badge (slot 9) is
|
||||
// driven by pd8.y, not the status-row flag array.
|
||||
bool slotActive = isVerifiedSlot ? (pd8.y > 0.5) : (statusFlag[iconSlot] > 0.5);
|
||||
if (!slotActive) {
|
||||
gl_Position = vec4(0.0);
|
||||
vUV = vec2(0.0);
|
||||
vLocalUV = vec2(0.0);
|
||||
@@ -168,21 +173,31 @@ void main() {
|
||||
// Icon world size: matches name text height
|
||||
float iconWorldSize = uFontBase * nameWorldScale * 1.1;
|
||||
|
||||
// Count active icons and position of this one (left-to-right)
|
||||
int totalActive = 0;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (statusFlag[i] > 0.5) totalActive++;
|
||||
float iconX;
|
||||
float iconY;
|
||||
if (isVerifiedSlot) {
|
||||
// Verified badge: anchored just right of the name text, sitting slightly
|
||||
// below the name line's vertical center (name glyphs center on wy).
|
||||
iconWorldSize = uFontBase * nameWorldScale * 0.9;
|
||||
iconX = wx + pd3.w * nameWorldScale + iconWorldSize * 0.12;
|
||||
iconY = wy - iconWorldSize * 0.4;
|
||||
} else {
|
||||
// Count active icons and position of this one (left-to-right)
|
||||
int totalActive = 0;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (statusFlag[i] > 0.5) totalActive++;
|
||||
}
|
||||
int myIndex = countBelow(iconSlot);
|
||||
|
||||
// Horizontal centering: spread icons evenly above the name
|
||||
float gap = iconWorldSize * 0.15;
|
||||
float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
|
||||
float startX = wx - totalWidth * 0.5;
|
||||
iconX = startX + float(myIndex) * (iconWorldSize + gap);
|
||||
|
||||
// Position: row above the emoji row
|
||||
iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
|
||||
}
|
||||
int myIndex = countBelow(iconSlot);
|
||||
|
||||
// Horizontal centering: spread icons evenly above the name
|
||||
float gap = iconWorldSize * 0.15;
|
||||
float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
|
||||
float startX = wx - totalWidth * 0.5;
|
||||
float iconX = startX + float(myIndex) * (iconWorldSize + gap);
|
||||
|
||||
// Position: row above the emoji row
|
||||
float iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
|
||||
|
||||
// Determine atlas index
|
||||
// Slots 0-6 map directly to atlas indices 0-6
|
||||
@@ -194,6 +209,9 @@ void main() {
|
||||
if (iconSlot == 8) {
|
||||
atlasIdx = 10; // doomsday-clock skull
|
||||
}
|
||||
if (isVerifiedSlot) {
|
||||
atlasIdx = 11; // blue verified check
|
||||
}
|
||||
|
||||
// Only the alliance icon (slot 3) gets the dark outline.
|
||||
vOutline = (iconSlot == 3) ? 1.0 : 0.0;
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PlayerStatic {
|
||||
flag?: string;
|
||||
/** Resolved crown-cosmetic image URL, or undefined for no crown. */
|
||||
crown?: string;
|
||||
/** Plays under the verified account username — blue check next to the name. */
|
||||
verified?: boolean;
|
||||
/** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user