class_name MatchChat extends Control ## The in-match chat box, bottom-left: a short scrollback of recent lines over an input ## field, with an all / team scope toggle, in the genre-standard corner. Pure presentation ## on the shared `UiTheme` palette, like the other code-built overlays. ## ## First pass is local only: a sent line is echoed straight into this client's own log and ## announced on `message_sent` — it does not yet travel to other players. Real all/team ## delivery is a v0.2 networking slice (the chat wire rides the same session as the snapshot ## stream); `message_sent` already carries the scope so that slice subscribes here without a ## rework. The input also gates the game keys: while the player is typing, `is_typing` is true ## and the driver suppresses ability casts, so a "q" in a message never fires Q. ## Fired when the player sends a line — `scope` is Scope.ALL/Scope.TEAM, `text` the message. ## The hook a later networking slice connects to deliver the line to the other clients. signal message_sent(scope: int, text: String) enum Scope { ALL, TEAM } ## How many lines the scrollback keeps; older lines drop off the top. const MAX_LINES := 8 const WIDTH := 380.0 const MARGIN := 18.0 ## Clears the input above the bottom HUD cluster so the two do not stack on the same row. const BOTTOM_OFFSET := 150.0 const FONT_SIZE := 15 const ALL_COLOR := Color(0.88, 0.89, 0.90) const TEAM_COLOR := Color(0.45, 0.78, 0.62) var _scope: int = Scope.ALL var _typing: bool = false var _log: VBoxContainer var _input: LineEdit var _scope_button: Button func _ready() -> void: set_anchors_preset(Control.PRESET_BOTTOM_LEFT) grow_vertical = Control.GROW_DIRECTION_BEGIN offset_left = MARGIN offset_bottom = -BOTTOM_OFFSET custom_minimum_size = Vector2(WIDTH, 0.0) mouse_filter = Control.MOUSE_FILTER_IGNORE _build() func _build() -> void: var column := VBoxContainer.new() column.add_theme_constant_override("separation", 4) column.custom_minimum_size = Vector2(WIDTH, 0.0) add_child(column) _log = VBoxContainer.new() _log.add_theme_constant_override("separation", 2) _log.mouse_filter = Control.MOUSE_FILTER_IGNORE column.add_child(_log) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) column.add_child(row) _scope_button = Button.new() _scope_button.theme = UiTheme.make() _scope_button.custom_minimum_size = Vector2(72.0, 0.0) _scope_button.pressed.connect(toggle_scope) row.add_child(_scope_button) _input = LineEdit.new() _input.theme = UiTheme.make() _input.size_flags_horizontal = Control.SIZE_EXPAND_FILL _input.max_length = 120 _input.visible = false _input.text_submitted.connect(_on_submitted) row.add_child(_input) _refresh_scope_button() # --- State ------------------------------------------------------------------ ## Whether the player is currently typing a message — read by the driver to suppress ability ## casts so the letters of a message never fire the QWER bar. func is_typing() -> bool: return _typing ## Opens the input for typing and focuses it, so the next keystrokes land in the message rather ## than the game. Idempotent — opening while already open just keeps the caret. func open() -> void: _typing = true _input.visible = true _input.grab_focus() ## Closes the input, drops focus, and clears any half-typed text, handing the keyboard back to ## the game. Called on send and on cancel. func close() -> void: _typing = false _input.visible = false _input.text = "" _input.release_focus() ## Flips the send scope between all-chat and team-chat, updating the toggle label. The next ## sent line carries the new scope. func toggle_scope() -> void: _scope = Scope.TEAM if _scope == Scope.ALL else Scope.ALL _refresh_scope_button() # --- Input ------------------------------------------------------------------ ## Opens chat on Enter when not already typing, and cancels on Escape while typing. Submitting ## a line is the LineEdit's own `text_submitted` (also Enter), so an open input never re-opens. func _unhandled_key_input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.pressed or event.echo: return if _typing: if event.keycode == KEY_ESCAPE: close() accept_event() elif event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER: open() accept_event() ## A submitted line: echo it into this client's own log and announce it, then close the input. ## A blank line just closes (the genre's "open, change your mind, hit enter" gesture). func _on_submitted(text: String) -> void: var trimmed := text.strip_edges() if trimmed != "": append_line("You", trimmed, _scope) message_sent.emit(_scope, trimmed) close() # --- Log -------------------------------------------------------------------- ## Appends a chat line to the scrollback, tagged by scope and tinted to match, trimming the ## oldest past the cap. Public so the later networking slice can drop remote players' lines in ## through the same path the local echo uses. func append_line(speaker: String, text: String, scope: int) -> void: var label := Label.new() label.text = "[%s] %s: %s" % [_scope_tag(scope), speaker, text] label.add_theme_font_size_override("font_size", FONT_SIZE) label.add_theme_color_override("font_color", _scope_color(scope)) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.custom_minimum_size = Vector2(WIDTH, 0.0) label.mouse_filter = Control.MOUSE_FILTER_IGNORE _log.add_child(label) while _log.get_child_count() > MAX_LINES: var oldest := _log.get_child(0) _log.remove_child(oldest) oldest.queue_free() func _refresh_scope_button() -> void: _scope_button.text = _scope_tag(_scope) _scope_button.add_theme_color_override("font_color", _scope_color(_scope)) func _scope_tag(scope: int) -> String: return "TEAM" if scope == Scope.TEAM else "ALL" func _scope_color(scope: int) -> Color: return TEAM_COLOR if scope == Scope.TEAM else ALL_COLOR