Everything related to chat functionality, and updating the projectile prefab to rotate properly
This commit is contained in:
parent
d92d00c83f
commit
66f84652dc
14 changed files with 1133 additions and 121 deletions
|
|
@ -37,6 +37,23 @@ namespace TD.UI
|
|||
[Header("Settings")]
|
||||
[SerializeField] private float rejectionMessageDuration = 2.5f;
|
||||
|
||||
[Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
|
||||
"height is clipped — older messages scroll off the top of the visible area " +
|
||||
"but stay in history (scroll up while chat is open to view).")]
|
||||
[SerializeField] private float chatMaxHeight = 280f;
|
||||
|
||||
[Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " +
|
||||
"(int.MaxValue) — every message sent during a match stays scrollable. " +
|
||||
"Lower the value if a long match ever shows DOM perf issues; this field " +
|
||||
"is the safety valve, not a normal-play limit.")]
|
||||
[SerializeField] private int chatMaxMessages = int.MaxValue;
|
||||
|
||||
[Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")]
|
||||
[SerializeField] private Color chatSystemColor = new Color(1f, 0.7f, 0.2f);
|
||||
|
||||
[Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")]
|
||||
[SerializeField] private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);
|
||||
|
||||
// ----- Cached UI element references -------------------------------
|
||||
|
||||
private Label goldLabel;
|
||||
|
|
@ -68,6 +85,25 @@ namespace TD.UI
|
|||
private VisualElement matchEndOverlay;
|
||||
private Label matchEndTitle;
|
||||
|
||||
// Chat panel (bottom-left, above portrait) — programmatic. The container
|
||||
// holds both the scrollable feed and the input. Highlight + scroll
|
||||
// interactivity are toggled on the container when typing.
|
||||
private VisualElement chatContainer;
|
||||
private ScrollView chatFeed;
|
||||
private TextField chatInput;
|
||||
private bool chatInputOpen;
|
||||
|
||||
// Frame on which the chat input was opened or closed. Enter on that frame
|
||||
// and the next one is ignored to prevent the open/close-triggering keypress
|
||||
// from also being consumed by the input or the open-toggle. Without this,
|
||||
// pressing Enter to open chat would immediately submit an empty message.
|
||||
private int chatToggleSuppressFrame = -1;
|
||||
|
||||
// Set true whenever the chat input or any other text field on the HUD has
|
||||
// keyboard focus. Camera, builder input, and hotkey handlers all gate on
|
||||
// this to keep typing from driving gameplay.
|
||||
public static bool IsTextInputActive { get; private set; }
|
||||
|
||||
// ----- State ------------------------------------------------------
|
||||
|
||||
private Coroutine rejectionFadeCoroutine;
|
||||
|
|
@ -247,6 +283,11 @@ namespace TD.UI
|
|||
// MatchState.OnPhaseChanged fires Victory or Defeat.
|
||||
BuildMatchEndOverlay(root);
|
||||
|
||||
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
|
||||
// Player typing toggled with Enter; system messages (e.g. life lost) post via
|
||||
// ChatService.PostLocalSystem on every peer.
|
||||
BuildChatPanel(root);
|
||||
|
||||
// Publish the panel so non-UI systems can query "is pointer over the HUD".
|
||||
// Stored on `myPanel` too so OnDestroy only clears the static if it still
|
||||
// points at this instance (defensive against re-creation overlap).
|
||||
|
|
@ -259,6 +300,8 @@ namespace TD.UI
|
|||
private void OnEnable()
|
||||
{
|
||||
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
|
||||
WaveManager.OnLifeLost += HandleLifeLost;
|
||||
ChatService.OnMessageReceived += HandleChatMessage;
|
||||
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
|
||||
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
|
||||
TrySubscribeSelection();
|
||||
|
|
@ -267,6 +310,8 @@ namespace TD.UI
|
|||
private void OnDisable()
|
||||
{
|
||||
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
||||
WaveManager.OnLifeLost -= HandleLifeLost;
|
||||
ChatService.OnMessageReceived -= HandleChatMessage;
|
||||
if (selectionSubscribed && SelectionState.Instance != null)
|
||||
{
|
||||
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
||||
|
|
@ -301,7 +346,13 @@ namespace TD.UI
|
|||
RefreshMatchStateDisplays();
|
||||
UpdateBuildProgressIfShown();
|
||||
UpdateEnemyInfoIfShown();
|
||||
HandleHotkeys();
|
||||
HandleChatInput();
|
||||
|
||||
// Skip gameplay hotkeys while the chat input is focused — letters
|
||||
// typed into chat should not also fire Q/W/E/R tower builds.
|
||||
if (!IsTextInputActive)
|
||||
HandleHotkeys();
|
||||
|
||||
minimapView?.Tick();
|
||||
}
|
||||
|
||||
|
|
@ -863,6 +914,375 @@ namespace TD.UI
|
|||
cameraController.JumpTo(t.position);
|
||||
}
|
||||
|
||||
// ----- Chat feed + input -----------------------------------------
|
||||
|
||||
// Bottom-left chat panel. Anchored 12px from the left edge, with the
|
||||
// bottom edge sitting above the 220px bottom-ui. Layout uses a flex
|
||||
// column: scrollable feed on top, input below. The feed clips at
|
||||
// chatMaxHeight; messages above that scroll off the top of the visible
|
||||
// area but stay in history (visible by scrolling once chat is open).
|
||||
private void BuildChatPanel(VisualElement root)
|
||||
{
|
||||
const float bottomUiHeight = 220f;
|
||||
const float gap = 8f;
|
||||
const float chatWidth = 380f;
|
||||
const float inputMinHeight = 32f;
|
||||
const float inputFeedGap = 6f; // breathing room between feed and input
|
||||
|
||||
// Container anchored to the bottom-left, growing upward as content
|
||||
// grows (no top/height set → height auto-fits content).
|
||||
chatContainer = new VisualElement();
|
||||
chatContainer.pickingMode = PickingMode.Ignore; // closed = wheel falls through
|
||||
chatContainer.style.position = Position.Absolute;
|
||||
chatContainer.style.left = 12;
|
||||
chatContainer.style.bottom = bottomUiHeight + gap;
|
||||
chatContainer.style.width = chatWidth;
|
||||
chatContainer.style.flexDirection = FlexDirection.Column;
|
||||
chatContainer.style.paddingTop = 4;
|
||||
chatContainer.style.paddingBottom = 4;
|
||||
chatContainer.style.paddingLeft = 6;
|
||||
chatContainer.style.paddingRight = 6;
|
||||
|
||||
// Feed: ScrollView so older messages can scroll off the top of the
|
||||
// visible window while staying in history. Vertical-only.
|
||||
chatFeed = new ScrollView(ScrollViewMode.Vertical);
|
||||
chatFeed.style.maxHeight = chatMaxHeight;
|
||||
chatFeed.style.flexShrink = 1;
|
||||
chatFeed.pickingMode = PickingMode.Ignore;
|
||||
chatFeed.contentViewport.pickingMode = PickingMode.Ignore;
|
||||
chatFeed.contentContainer.pickingMode = PickingMode.Ignore;
|
||||
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;
|
||||
|
||||
// Belt-and-suspenders wheel interceptor. The recursive .hierarchy
|
||||
// pickingMode walk should already prevent wheel events from reaching
|
||||
// chatFeed when chat is closed; this handler is defense-in-depth
|
||||
// against future Unity versions reorganizing ScrollView internals.
|
||||
chatFeed.RegisterCallback<WheelEvent>(evt =>
|
||||
{
|
||||
if (!chatInputOpen)
|
||||
evt.StopImmediatePropagation();
|
||||
}, TrickleDown.TrickleDown);
|
||||
// Track-style — make the scrollbar slim and dark so it doesn't compete with messages.
|
||||
chatFeed.style.marginBottom = inputFeedGap;
|
||||
chatContainer.Add(chatFeed);
|
||||
|
||||
// Input field. Hidden by default. We use minHeight (not a fixed height)
|
||||
// because the inner unity-text-input element needs vertical padding
|
||||
// for the font to render fully — pinning to 28px clipped descenders
|
||||
// and ascenders. We also style the inner child directly because the
|
||||
// TextField's visible white background lives on it, not on the root.
|
||||
chatInput = new TextField();
|
||||
chatInput.style.minHeight = inputMinHeight;
|
||||
chatInput.style.width = Length.Percent(100);
|
||||
chatInput.maxLength = 120;
|
||||
chatInput.style.display = DisplayStyle.None;
|
||||
chatInput.isDelayed = false;
|
||||
StyleChatInputDark(chatInput);
|
||||
|
||||
// Focus tracking — gameplay input gates on IsTextInputActive.
|
||||
chatInput.RegisterCallback<FocusInEvent>(_ => IsTextInputActive = true);
|
||||
chatInput.RegisterCallback<FocusOutEvent>(_ => IsTextInputActive = false);
|
||||
|
||||
// Submit (Enter) and cancel (Escape) are handled in Update via
|
||||
// direct Input System reads — not via UI Toolkit's NavigationSubmit
|
||||
// event, which fires inconsistently relative to TextField.value
|
||||
// commit timing. See HandleChatInput.
|
||||
|
||||
chatContainer.Add(chatInput);
|
||||
|
||||
root.Add(chatContainer);
|
||||
|
||||
// Initial state is closed → every chat descendant (including the
|
||||
// ScrollView's internal scroll-container wrapper) starts Ignore so
|
||||
// panel.Pick falls through to nothing and the camera owns the wheel.
|
||||
SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
|
||||
}
|
||||
|
||||
// Recursively sets pickingMode on every visual descendant of root.
|
||||
//
|
||||
// IMPORTANT: this uses .hierarchy[i] / .hierarchy.childCount, NOT the
|
||||
// root[i] indexer. VisualElement has two hierarchies:
|
||||
//
|
||||
// * Logical hierarchy (root[i] / root.childCount) — children added by
|
||||
// user code via Add(). For elements with a redirected contentContainer
|
||||
// (ScrollView, Foldout, etc.), this only enumerates the user content,
|
||||
// not the internal scaffolding.
|
||||
//
|
||||
// * Visual hierarchy (root.hierarchy[i]) — the actual visual tree
|
||||
// including internal scaffolding (ScrollView's
|
||||
// unity-content-and-vertical-scroll-container, viewport, scrollers,
|
||||
// etc.).
|
||||
//
|
||||
// We need the visual hierarchy because the wrapper element
|
||||
// unity-content-and-vertical-scroll-container is what panel.Pick was
|
||||
// landing on. Walking the logical hierarchy would visit ScrollView's
|
||||
// user-added chat lines but skip the internal wrapper entirely.
|
||||
private static void SetPickingModeRecursive(VisualElement root, PickingMode mode)
|
||||
{
|
||||
if (root == null) return;
|
||||
root.pickingMode = mode;
|
||||
int count = root.hierarchy.childCount;
|
||||
for (int i = 0; i < count; i++)
|
||||
SetPickingModeRecursive(root.hierarchy[i], mode);
|
||||
}
|
||||
|
||||
// The TextField root color isn't where the visible background lives — it's
|
||||
// on the inner "unity-text-input" element. Style both: the visible
|
||||
// background goes dark, the text goes white so what the player types is
|
||||
// legible against it, and the inner element gets a few px of vertical
|
||||
// padding so characters with ascenders/descenders (capital letters,
|
||||
// lowercase y/g/p/q/j, "?") don't get clipped.
|
||||
private static void StyleChatInputDark(TextField field)
|
||||
{
|
||||
var darkBg = new Color(0.10f, 0.10f, 0.10f, 0.95f);
|
||||
var borderClr = new Color(0.45f, 0.45f, 0.5f);
|
||||
|
||||
field.style.backgroundColor = darkBg;
|
||||
field.style.color = Color.white;
|
||||
field.style.borderTopWidth = field.style.borderBottomWidth =
|
||||
field.style.borderLeftWidth = field.style.borderRightWidth = 1;
|
||||
field.style.borderTopColor = field.style.borderBottomColor =
|
||||
field.style.borderLeftColor = field.style.borderRightColor = borderClr;
|
||||
|
||||
// The actual editable area child. It exists immediately on construction.
|
||||
var inner = field.Q("unity-text-input");
|
||||
if (inner != null)
|
||||
{
|
||||
inner.style.backgroundColor = darkBg;
|
||||
inner.style.color = Color.white;
|
||||
inner.style.paddingTop = 4;
|
||||
inner.style.paddingBottom = 4;
|
||||
inner.style.paddingLeft = 6;
|
||||
inner.style.paddingRight = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Called every frame from Update. Handles all three chat key bindings
|
||||
// via direct Input System reads:
|
||||
// - Enter (chat closed) → open input
|
||||
// - Enter (chat open) → submit + close
|
||||
// - Escape (chat open) → close without submit
|
||||
//
|
||||
// Reading the Input System directly avoids two UI Toolkit quirks:
|
||||
// 1. The first Enter inside a focused TextField is sometimes consumed
|
||||
// by the field's internal edit-mode handler before NavigationSubmit
|
||||
// can fire, requiring a SECOND Enter to trigger the user-visible
|
||||
// submit. Going through Keyboard.current sidesteps that pipeline.
|
||||
// 2. KeyDownEvent / NavigationSubmitEvent fire timing isn't aligned
|
||||
// with TextField.value commit on every Unity version. Reading the
|
||||
// Input System happens at a deterministic point in Update where
|
||||
// TextField.value is already up to date (since isDelayed = false).
|
||||
private void HandleChatInput()
|
||||
{
|
||||
if (chatInput == null) return;
|
||||
|
||||
var kb = Keyboard.current;
|
||||
if (kb == null) return;
|
||||
|
||||
// Suppression window covers the frame after open/close so the same
|
||||
// keypress that toggled chat doesn't immediately fire the opposite path.
|
||||
if (Time.frameCount <= chatToggleSuppressFrame) return;
|
||||
|
||||
bool enterDown = kb.enterKey.wasPressedThisFrame
|
||||
|| kb.numpadEnterKey.wasPressedThisFrame;
|
||||
bool escDown = kb.escapeKey.wasPressedThisFrame;
|
||||
|
||||
if (!chatInputOpen)
|
||||
{
|
||||
if (enterDown) OpenChatInput();
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat is open.
|
||||
if (enterDown) SubmitChatInput();
|
||||
else if (escDown) CloseChatInput(submit: false);
|
||||
}
|
||||
|
||||
private void OpenChatInput()
|
||||
{
|
||||
if (chatInput == null) return;
|
||||
chatInput.style.display = DisplayStyle.Flex;
|
||||
chatInput.SetValueWithoutNotify(string.Empty);
|
||||
|
||||
// Switch the chat panel from "passive display" to "interactive":
|
||||
// - Container gets a dark translucent background as a visual cue
|
||||
// AND becomes pointer-active so clicks on the highlight don't
|
||||
// fall through and place towers / deselect units underneath.
|
||||
// - Feed becomes pointer-active so wheel events scroll it (and
|
||||
// so IsPointerOverInteractiveHud picks the chat for camera-gate).
|
||||
// - Scrollbar becomes visible.
|
||||
if (chatContainer != null)
|
||||
chatContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.40f);
|
||||
if (chatFeed != null)
|
||||
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Auto;
|
||||
|
||||
// Make EVERY descendant of the chat panel interactive. Setting Position
|
||||
// recursively covers ScrollView's internal scroll-container wrapper
|
||||
// (which our previous explicit list of three pickingMode targets
|
||||
// missed), so panel.Pick will reliably land on a chat element and
|
||||
// wheel events will reach the ScrollView's manipulator.
|
||||
SetPickingModeRecursive(chatContainer, PickingMode.Position);
|
||||
|
||||
// Suppress Enter for this frame and the next so the keypress that
|
||||
// opened chat doesn't also submit it empty. Focus is deferred a frame
|
||||
// for the same reason — UI Toolkit would otherwise route the open-Enter
|
||||
// to the freshly-focused TextField immediately.
|
||||
chatToggleSuppressFrame = Time.frameCount + 1;
|
||||
chatInputOpen = true;
|
||||
StartCoroutine(FocusChatNextFrame());
|
||||
}
|
||||
|
||||
private IEnumerator FocusChatNextFrame()
|
||||
{
|
||||
yield return null;
|
||||
if (chatInputOpen && chatInput != null)
|
||||
{
|
||||
chatInput.Focus();
|
||||
// Focus alone doesn't always activate the text caret immediately.
|
||||
// SelectAll forces the field into edit mode reliably across versions.
|
||||
chatInput.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseChatInput(bool submit)
|
||||
{
|
||||
if (chatInput == null) return;
|
||||
if (submit) SubmitChatInput();
|
||||
RevertChatPanelToPassive();
|
||||
}
|
||||
|
||||
private void SubmitChatInput()
|
||||
{
|
||||
string text = chatInput?.value ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(text) && ChatService.Instance != null)
|
||||
ChatService.Instance.SubmitMessage(text);
|
||||
|
||||
RevertChatPanelToPassive();
|
||||
}
|
||||
|
||||
// Single source of truth for "chat is no longer in typing mode" — clears the
|
||||
// input, hides it, restores pickingMode = Ignore on every layer that flipped
|
||||
// to Position on Open, drops the highlight background, and flips the debug
|
||||
// border back to red. Both Submit and Close route through here so the two
|
||||
// close paths can't drift out of sync (which is exactly what caused the
|
||||
// "border stays green / wheel keeps scrolling" bug).
|
||||
private void RevertChatPanelToPassive()
|
||||
{
|
||||
if (chatInput != null)
|
||||
{
|
||||
chatInput.SetValueWithoutNotify(string.Empty);
|
||||
chatInput.style.display = DisplayStyle.None;
|
||||
chatInput.Blur();
|
||||
}
|
||||
|
||||
if (chatContainer != null)
|
||||
chatContainer.style.backgroundColor = StyleKeyword.Initial;
|
||||
if (chatFeed != null)
|
||||
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;
|
||||
|
||||
// Same as Open's recursive Position, but Ignore — covers ScrollView's
|
||||
// internal scroll-container wrapper so panel.Pick falls through to
|
||||
// whatever's behind chat (which is nothing, so IsPointerOverInteractiveHud
|
||||
// returns false and the camera processes wheel events normally).
|
||||
SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
|
||||
|
||||
chatInputOpen = false;
|
||||
chatToggleSuppressFrame = Time.frameCount + 1;
|
||||
}
|
||||
|
||||
// Subscribed to ChatService.OnMessageReceived. Appends a new line to
|
||||
// the feed, prunes the oldest if we exceed the history cap, and
|
||||
// auto-scrolls only if the player was already viewing the latest
|
||||
// messages — preserving their position if they've scrolled up to
|
||||
// read older history.
|
||||
private void HandleChatMessage(ChatService.ChatEntry entry)
|
||||
{
|
||||
if (chatFeed == null) return;
|
||||
|
||||
// Capture "was the user at the bottom" BEFORE we append, while the
|
||||
// layout still reflects the pre-message state. If they were, we
|
||||
// re-pin to the new bottom after the message lays out. If they
|
||||
// weren't (scrolled up reading history), we leave their position
|
||||
// alone so new messages don't yank them back.
|
||||
bool wasAtBottom = !chatInputOpen || IsChatScrolledToBottom();
|
||||
|
||||
var line = BuildChatLine(entry);
|
||||
chatFeed.Add(line);
|
||||
|
||||
// Bound history. ScrollView.contentContainer is what actually holds
|
||||
// child elements (the ScrollView root has its own layout chrome).
|
||||
// Default chatMaxMessages is effectively unlimited so this loop is
|
||||
// a no-op in normal play; the cap exists as a safety valve.
|
||||
var content = chatFeed.contentContainer;
|
||||
while (content.childCount > chatMaxMessages)
|
||||
content.RemoveAt(0);
|
||||
|
||||
if (!wasAtBottom) return;
|
||||
|
||||
// Defer scroll-to-bottom until layout has resolved the new line's
|
||||
// height — without the delay, contentContainer.layout.height is
|
||||
// stale and we'd scroll to the previous max.
|
||||
chatFeed.schedule.Execute(() =>
|
||||
{
|
||||
if (chatFeed == null) return;
|
||||
var height = chatFeed.contentContainer.layout.height;
|
||||
chatFeed.scrollOffset = new Vector2(0, Mathf.Max(0, height));
|
||||
}).ExecuteLater(1);
|
||||
}
|
||||
|
||||
// True if the feed isn't overflowing yet, or the player's scroll
|
||||
// position is within a few pixels of the bottom edge of the content.
|
||||
// Used to decide whether to auto-scroll on new messages.
|
||||
private bool IsChatScrolledToBottom()
|
||||
{
|
||||
if (chatFeed == null) return true;
|
||||
|
||||
float viewportHeight = chatFeed.contentViewport.layout.height;
|
||||
float contentHeight = chatFeed.contentContainer.layout.height;
|
||||
|
||||
// Not overflowing → effectively "at the bottom" (everything visible).
|
||||
if (contentHeight <= viewportHeight + 0.5f) return true;
|
||||
|
||||
float maxScroll = contentHeight - viewportHeight;
|
||||
const float tolerance = 4f;
|
||||
return chatFeed.scrollOffset.y >= maxScroll - tolerance;
|
||||
}
|
||||
|
||||
private Label BuildChatLine(ChatService.ChatEntry entry)
|
||||
{
|
||||
var line = new Label();
|
||||
line.pickingMode = PickingMode.Ignore;
|
||||
line.style.marginBottom = 2;
|
||||
line.style.color = entry.Kind == ChatService.MessageKind.System
|
||||
? chatSystemColor
|
||||
: chatPlayerColor;
|
||||
line.style.whiteSpace = WhiteSpace.Normal; // wrap long messages
|
||||
|
||||
// Soft drop shadow so messages stay readable over varied backgrounds.
|
||||
line.style.textShadow = new TextShadow
|
||||
{
|
||||
offset = new Vector2(1, 1),
|
||||
blurRadius = 2,
|
||||
color = new Color(0f, 0f, 0f, 0.85f),
|
||||
};
|
||||
|
||||
line.text = entry.Kind == ChatService.MessageKind.System
|
||||
? entry.Text
|
||||
: $"[{entry.SenderName}] {entry.Text}";
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
// Fires every time WaveManager.OnLifeLost is invoked (one event per leak).
|
||||
// Routes the notification through ChatService as a SYSTEM message so it
|
||||
// shares the feed with player chat. The lives counter in the top bar
|
||||
// still updates independently via MatchState replication.
|
||||
private void HandleLifeLost(int amount)
|
||||
{
|
||||
string text = amount == 1 ? "1 Life Lost" : $"{amount} Lives Lost";
|
||||
ChatService.PostLocalSystem(text);
|
||||
}
|
||||
|
||||
// ----- Match-end overlay (Victory / Defeat + Retry) ---------------
|
||||
|
||||
private void BuildMatchEndOverlay(VisualElement root)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue