GDScript 167 lines
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