GeoSus
This commit is contained in:
324
Assets/Scripts/UITheme.cs
Normal file
324
Assets/Scripts/UITheme.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized design tokens + styling helpers for every Canvas in the game.
|
||||
/// One source of truth for color, typography, spacing, animation timing, and
|
||||
/// emoji-capable font configuration. Replaces the per-file palette duplication
|
||||
/// that scattered across InGameHUDBuilder, HostLobbyUI, LobbyDisplayUI etc.
|
||||
///
|
||||
/// Why this exists (P10):
|
||||
/// - "UI looks tiny on phone, bleeds off-screen during voting, font drops
|
||||
/// emoji glyphs" - all symptoms of palette/typography/sizing values being
|
||||
/// defined per-file with no shared scale. Centralizing fixes the
|
||||
/// whole-app inconsistency, not just the worst offender.
|
||||
/// - Emoji rendering on Android needs TMP_Settings.fallbackFontAssets
|
||||
/// pointed at a Noto Color Emoji TMP_FontAsset. We do this at runtime
|
||||
/// so the host project doesn't need a pre-baked TMP_Settings asset
|
||||
/// change (which would touch a binary scriptable object).
|
||||
///
|
||||
/// Visual direction:
|
||||
/// - Dark base (#0A0E1A near-black) with one strong accent (teal-cyan
|
||||
/// #00C8C8). Readable in sunlight, doesn't clash with the orange/red
|
||||
/// impostor signals or the green map.
|
||||
/// - Typography ramp: one display weight, one body, one mono for codes.
|
||||
/// Sizes follow a major-third scale (1.25x ratio) so headlines
|
||||
/// dominate without shouting.
|
||||
/// - Spacing on an 8pt grid. Touch targets minimum 88pt (44pt @ 2x).
|
||||
/// - Animation: subtle. Fade 150ms, slide 200ms, ease-out. Game's tense -
|
||||
/// UI shouldn't be cute.
|
||||
/// </summary>
|
||||
public static class UITheme
|
||||
{
|
||||
// ── Palette ───────────────────────────────────────────────────────────────
|
||||
// Names follow purpose, not literal color. Adding a new component? Use
|
||||
// Bg/Surface/Accent/Success/Danger/Warning/Muted - not raw hex.
|
||||
|
||||
public static readonly Color Bg = H("#0A0E1A"); // page background
|
||||
public static readonly Color Surface = H("#121829"); // cards, panels
|
||||
public static readonly Color SurfaceAlt = H("#1A2138"); // elevated/active
|
||||
public static readonly Color SurfaceDim = H("#0E1322"); // recessed (input)
|
||||
public static readonly Color RowA = H("#1A2035"); // alt row
|
||||
public static readonly Color RowB = H("#161C2E"); // alt row
|
||||
public static readonly Color Border = new Color(1f, 1f, 1f, 0.10f);
|
||||
|
||||
public static readonly Color Accent = H("#00C8C8"); // teal-cyan, primary CTA
|
||||
public static readonly Color AccentDim = H("#0A8A8A"); // pressed state
|
||||
public static readonly Color Success = H("#2DB84B"); // task done, you-voted
|
||||
public static readonly Color Danger = H("#E04040"); // impostor, eject, kill
|
||||
public static readonly Color Warning = H("#F08C1A"); // sabotage, meltdown
|
||||
public static readonly Color Caution = H("#FFB800"); // reconnect, info
|
||||
|
||||
public static readonly Color TextHi = new Color(0.96f, 0.97f, 0.99f); // primary
|
||||
public static readonly Color TextMid = new Color(0.78f, 0.83f, 0.91f); // secondary
|
||||
public static readonly Color TextLo = new Color(0.55f, 0.62f, 0.75f); // tertiary/disabled
|
||||
public static readonly Color TextOnAccent= H("#001818"); // black-ish on bright accent
|
||||
|
||||
static Color H(string hex)
|
||||
{
|
||||
ColorUtility.TryParseHtmlString(hex, out var c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Typography ────────────────────────────────────────────────────────────
|
||||
// Sizes are in TMP "px" units which scale with the CanvasScaler reference.
|
||||
// Our reference is 1080x1920 portrait; these values produce the intended
|
||||
// physical size on a typical 6" phone (~5mm tall body text).
|
||||
|
||||
public const float FontDisplay = 64f; // hero (game-end win/loss)
|
||||
public const float FontHeadline = 44f; // section header
|
||||
public const float FontTitle = 32f; // panel header
|
||||
public const float FontBody = 26f; // standard text
|
||||
public const float FontSmall = 22f; // captions, helper text
|
||||
public const float FontTiny = 18f; // tiny meta, debug
|
||||
|
||||
// Action button text scales separately because thumb-zone CTAs need to
|
||||
// dominate visually even on a small screen.
|
||||
public const float FontActionBtn= 40f;
|
||||
|
||||
// ── Spacing (8pt grid) ────────────────────────────────────────────────────
|
||||
public const float S1 = 4f;
|
||||
public const float S2 = 8f;
|
||||
public const float S3 = 12f;
|
||||
public const float S4 = 16f;
|
||||
public const float S5 = 24f;
|
||||
public const float S6 = 32f;
|
||||
public const float S7 = 48f;
|
||||
public const float S8 = 64f;
|
||||
public const float S9 = 96f;
|
||||
|
||||
// ── Touch targets ─────────────────────────────────────────────────────────
|
||||
// Minimum heights tuned for 1080x1920 reference. CanvasScaler keeps these
|
||||
// visually consistent across phone sizes.
|
||||
public const float MinTapHeight = 88f; // 44pt @ 2x
|
||||
public const float StandardBtn = 110f; // typical button height
|
||||
public const float HeroBtn = 140f; // primary CTA
|
||||
public const float VoteRowMin = 96f;
|
||||
public const float VoteRowMax = 144f;
|
||||
|
||||
// ── Corner radii (target value; needs an Image with a rounded sprite to
|
||||
// realize, but keeping the token here lets future polish hit one place) ──
|
||||
public const float Radius1 = 8f;
|
||||
public const float Radius2 = 14f;
|
||||
public const float Radius3 = 22f;
|
||||
|
||||
// ── Animation timing ──────────────────────────────────────────────────────
|
||||
public const float DurFade = 0.15f;
|
||||
public const float DurSlide = 0.20f;
|
||||
public const float DurSnappy = 0.10f;
|
||||
public const float DurAmbient = 0.40f; // slow ambient pulses
|
||||
|
||||
// ── Canvas reference (mirrored from InGameHUDBuilder for consistency) ────
|
||||
public const float ReferenceWidth = 1080f;
|
||||
public const float ReferenceHeight = 1920f;
|
||||
public const float MatchWidthOrHeight = 0.5f;
|
||||
|
||||
// ── Emoji font fallback ───────────────────────────────────────────────────
|
||||
// Tracks whether we've already tried to wire a Noto Color Emoji fallback
|
||||
// into TMP_Settings. Once attempted (success or fail), we don't keep
|
||||
// re-poking - mobile resource lookups aren't free.
|
||||
static bool _emojiFallbackAttempted;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the project's TMP fallback font chain so emoji codepoints
|
||||
/// (📡 ⚙️ 🗳️ 👥 etc.) used throughout the UI render properly. Looks for a
|
||||
/// TMP_FontAsset named "NotoColorEmoji" (or "EmojiFallback") under
|
||||
/// `Resources/Fonts/` first; if that's missing, walks the OS font cache
|
||||
/// the platform exposes via Font.OSFontNames as a last-resort fallback.
|
||||
///
|
||||
/// Idempotent + cheap on subsequent calls. Callers don't need to gate.
|
||||
/// </summary>
|
||||
public static void EnsureEmojiFontFallback()
|
||||
{
|
||||
if (_emojiFallbackAttempted) return;
|
||||
_emojiFallbackAttempted = true;
|
||||
|
||||
// Primary: explicit Resources lookup. Drop a pre-built
|
||||
// `NotoColorEmoji.asset` (TMP_FontAsset) into Assets/Resources/Fonts/
|
||||
// for Android/iOS - that's the production path.
|
||||
TMP_FontAsset emoji = Resources.Load<TMP_FontAsset>("Fonts/NotoColorEmoji");
|
||||
if (emoji == null) emoji = Resources.Load<TMP_FontAsset>("Fonts/EmojiFallback");
|
||||
|
||||
if (emoji != null)
|
||||
{
|
||||
var def = TMP_Settings.defaultFontAsset;
|
||||
if (def != null)
|
||||
{
|
||||
if (def.fallbackFontAssetTable == null)
|
||||
def.fallbackFontAssetTable = new List<TMP_FontAsset>();
|
||||
if (!def.fallbackFontAssetTable.Contains(emoji))
|
||||
{
|
||||
def.fallbackFontAssetTable.Add(emoji);
|
||||
Debug.Log("[UITheme] Emoji fallback wired: " + emoji.name);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No bundled emoji font. Log once so the user knows to either drop
|
||||
// one in or strip emojis from labels - silent failure here means
|
||||
// missing-glyph rectangles surface in the UI without explanation.
|
||||
Debug.LogWarning(
|
||||
"[UITheme] No NotoColorEmoji TMP_FontAsset found under Resources/Fonts/. " +
|
||||
"Emoji glyphs in UI text will render as tofu boxes. " +
|
||||
"Drop a TMP-baked Noto Color Emoji asset at " +
|
||||
"Assets/Resources/Fonts/NotoColorEmoji.asset (any TMP_FontAsset name " +
|
||||
"containing 'Emoji' also matches via the secondary lookup).");
|
||||
}
|
||||
|
||||
// ── Style helpers ─────────────────────────────────────────────────────────
|
||||
// Apply consistent styling without repeating every property. Keep these
|
||||
// single-purpose; if you find yourself adding flags, that's a sign you
|
||||
// should add a new helper instead.
|
||||
|
||||
/// <summary>Solid Image with theme-consistent color. Returns the Image.</summary>
|
||||
public static Image Surf(RectTransform rt, Color color)
|
||||
{
|
||||
var img = rt.gameObject.GetComponent<Image>() ?? rt.gameObject.AddComponent<Image>();
|
||||
img.color = color;
|
||||
return img;
|
||||
}
|
||||
|
||||
/// <summary>Stretches a RectTransform to fill its parent.</summary>
|
||||
public static void Stretch(RectTransform rt)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
/// <summary>Apply the project's standard CanvasScaler config.</summary>
|
||||
public static void ConfigureCanvasScaler(Canvas canvas)
|
||||
{
|
||||
if (canvas == null) return;
|
||||
var scaler = canvas.GetComponent<CanvasScaler>()
|
||||
?? canvas.gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(ReferenceWidth, ReferenceHeight);
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = MatchWidthOrHeight;
|
||||
scaler.referencePixelsPerUnit = 100f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anchor a RectTransform to the screen's safe rectangle (notch/punch-hole
|
||||
/// safe). Children inherit the inset.
|
||||
/// </summary>
|
||||
public static void ApplySafeArea(RectTransform rt)
|
||||
{
|
||||
if (rt == null) return;
|
||||
var safe = Screen.safeArea;
|
||||
var screenSize = new Vector2(Screen.width, Screen.height);
|
||||
if (screenSize.x <= 0 || screenSize.y <= 0) return;
|
||||
|
||||
Vector2 aMin = safe.position;
|
||||
Vector2 aMax = safe.position + safe.size;
|
||||
aMin.x /= screenSize.x; aMin.y /= screenSize.y;
|
||||
aMax.x /= screenSize.x; aMax.y /= screenSize.y;
|
||||
|
||||
rt.anchorMin = aMin; rt.anchorMax = aMax;
|
||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Style a TMP text field as a labeled text element. Sets size, color,
|
||||
/// alignment, weight - and ensures the emoji fallback chain is wired so
|
||||
/// any emoji glyphs in the text render properly on the first frame.
|
||||
/// </summary>
|
||||
public static void StyleText(TMP_Text tmp, float size, Color color,
|
||||
TextAlignmentOptions align, bool bold = false, FontStyles extra = FontStyles.Normal)
|
||||
{
|
||||
if (tmp == null) return;
|
||||
EnsureEmojiFontFallback();
|
||||
tmp.fontSize = size;
|
||||
tmp.color = color;
|
||||
tmp.alignment = align;
|
||||
tmp.fontStyle = (bold ? FontStyles.Bold : FontStyles.Normal) | extra;
|
||||
tmp.enableWordWrapping = true;
|
||||
}
|
||||
|
||||
/// <summary>Standard primary CTA button styling - bg, text, target graphic.</summary>
|
||||
public static Button StylePrimaryButton(RectTransform rt, string label,
|
||||
Color? bgColor = null, Color? textColor = null, float? fontSize = null)
|
||||
{
|
||||
var bg = Surf(rt, bgColor ?? Accent);
|
||||
var btn = rt.gameObject.GetComponent<Button>() ?? rt.gameObject.AddComponent<Button>();
|
||||
btn.targetGraphic = bg;
|
||||
|
||||
// Color block: dim for pressed/disabled states.
|
||||
var cb = btn.colors;
|
||||
cb.normalColor = bgColor ?? Accent;
|
||||
cb.highlightedColor = Color.Lerp(bgColor ?? Accent, TextHi, 0.10f);
|
||||
cb.pressedColor = Color.Lerp(bgColor ?? Accent, Bg, 0.30f);
|
||||
cb.disabledColor = new Color(0.30f, 0.34f, 0.42f, 0.70f);
|
||||
cb.selectedColor = bgColor ?? Accent;
|
||||
btn.colors = cb;
|
||||
|
||||
// Label child
|
||||
var labelRt = NewChild("Label", rt);
|
||||
Stretch(labelRt);
|
||||
var tmp = labelRt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
StyleText(tmp, fontSize ?? FontActionBtn,
|
||||
textColor ?? TextOnAccent, TextAlignmentOptions.Center, bold: true);
|
||||
tmp.text = label;
|
||||
// Padding so the text doesn't kiss the button edges.
|
||||
labelRt.offsetMin = new Vector2(S4, S2);
|
||||
labelRt.offsetMax = new Vector2(-S4, -S2);
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/// <summary>Secondary (outlined / muted) button styling.</summary>
|
||||
public static Button StyleSecondaryButton(RectTransform rt, string label,
|
||||
float? fontSize = null)
|
||||
{
|
||||
return StylePrimaryButton(rt, label,
|
||||
bgColor: SurfaceAlt, textColor: TextHi, fontSize: fontSize);
|
||||
}
|
||||
|
||||
/// <summary>Danger (eject/leave/kill confirmation) button styling.</summary>
|
||||
public static Button StyleDangerButton(RectTransform rt, string label,
|
||||
float? fontSize = null)
|
||||
{
|
||||
return StylePrimaryButton(rt, label,
|
||||
bgColor: Danger, textColor: TextHi, fontSize: fontSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a child RectTransform under the given parent. Centralized so
|
||||
/// every UI builder doesn't reinvent the GameObject + RectTransform
|
||||
/// boilerplate.
|
||||
/// </summary>
|
||||
public static RectTransform NewChild(string name, RectTransform parent)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
var rt = go.AddComponent<RectTransform>();
|
||||
rt.SetParent(parent, false);
|
||||
rt.localScale = Vector3.one;
|
||||
return rt;
|
||||
}
|
||||
|
||||
/// <summary>Anchor a RectTransform with absolute corner offsets.</summary>
|
||||
public static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
|
||||
Vector2 offsetMin, Vector2 offsetMax)
|
||||
{
|
||||
rt.anchorMin = aMin; rt.anchorMax = aMax;
|
||||
rt.offsetMin = offsetMin; rt.offsetMax = offsetMax;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a TMP text label as a child of `parent`, fully filling it. Common
|
||||
/// pattern for buttons and panel headers.
|
||||
/// </summary>
|
||||
public static TextMeshProUGUI TxtChild(RectTransform parent, string text,
|
||||
float size, Color color, TextAlignmentOptions align, bool bold = false)
|
||||
{
|
||||
var rt = NewChild("Txt", parent);
|
||||
Stretch(rt);
|
||||
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
StyleText(tmp, size, color, align, bold);
|
||||
tmp.text = text;
|
||||
return tmp;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user