extends Control
## In-game Keet chat overlay. Uses KeetService autoload for daemon access.
## Chat room is determined by KeetService.room_selected (from network panel).
##
## T toggles chat panel. Enter focuses the input for typing.
## Escape or clicking outside releases focus back to the game.
@onready var chat_log: RichTextLabel = $ChatPanel/VBox/ChatLog
@onready var msg_input: LineEdit = $ChatPanel/VBox/InputRow/MessageInput
@onready var room_label: Label = $ChatPanel/VBox/Header/RoomLabel
const KeetNames := preload("res://addons/keet_social/keet_names.gd")
var _chat_visible := true
var _input_focused := false
func _ready() -> void:
# Wire KeetService signals
KeetService.client.connected.connect(_on_connected)
KeetService.client.disconnected.connect(_on_disconnected)
KeetService.chat.message_received.connect(_on_message)
KeetService.chat.messages_loaded.connect(_on_history)
KeetService.room_selected.connect(_on_room_selected)
# Start visible but WITHOUT input focus (so WASD works for game)
$ChatPanel.visible = true
msg_input.focus_mode = Control.FOCUS_CLICK # Only focus on click, not tab
msg_input.placeholder_text = "Enter to chat..."
# Track focus state and update hint
msg_input.focus_entered.connect(func() -> void:
_input_focused = true
msg_input.placeholder_text = "Type message... (Esc to cancel)"
)
msg_input.focus_exited.connect(func() -> void:
_input_focused = false
msg_input.placeholder_text = "Enter to chat..."
)
# Connect UI
$ChatPanel/VBox/InputRow/SendBtn.pressed.connect(_send)
msg_input.text_submitted.connect(func(_t: String) -> void: _send())
$ChatPanel/VBox/Header/CloseBtn.pressed.connect(toggle)
# Show initial hint
room_label.text = "Chat [T]toggle [Enter]type"
# If daemon is already connected and room selected, start immediately
if KeetService.client.is_connected:
_on_connected()
func _unhandled_input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed:
return
# T toggles the chat panel (handled here so it works even before the player spawns).
# Through the KEYMAP, not the raw KEY_T: toggle_chat is a `player` action, so build
# mode suppresses it (user 2026-10-18: "t still brings up chat menu in editor which is
# very wrong"), and an exact match, so the editor's Ctrl+T is not chat either.
if Keymap.tapped(event, "toggle_chat") and not _input_focused:
toggle()
get_viewport().set_input_as_handled()
return
# Enter key focuses the chat input (when chat is visible and input not focused)
if _chat_visible and not _input_focused:
if event.keycode == KEY_ENTER:
msg_input.grab_focus()
get_viewport().set_input_as_handled()
return
# Escape releases chat input focus back to game
if _input_focused:
if event.keycode == KEY_ESCAPE:
msg_input.release_focus()
get_viewport().set_input_as_handled()
return
func toggle() -> void:
_chat_visible = not _chat_visible
$ChatPanel.visible = _chat_visible
# Release focus when hiding so game input works
if not _chat_visible:
msg_input.release_focus()
func _on_connected() -> void:
KeetService.identity.get_identity()
room_label.text = "Chat (connected) [T]hide [Enter]type"
func _on_disconnected() -> void:
room_label.text = "Chat (offline) [T]hide [Enter]type"
func _on_room_selected(room_id: String, title: String) -> void:
chat_log.clear()
if room_id.is_empty():
room_label.text = "Chat (no room) [T]hide [Enter]type"
else:
room_label.text = "%s [T]hide [Enter]type" % title
# Load history for the new room
KeetService.chat.load_history(30)
func _on_message(msg: Dictionary) -> void:
_append_msg(msg)
func _on_history(msgs: Array) -> void:
chat_log.clear()
for m: Variant in msgs:
if m is Dictionary:
_append_msg(m)
func _append_msg(msg: Dictionary) -> void:
# keet-daemon's `read` is FLAT {memberId, name, text}; KeetNames also takes
# the older nested {sender:{displayName}} shape. It never yields "?" or a
# made-up name: a nameless member reads "anon-<first 4 of member id>".
var display_name := KeetNames.for_message(msg)
var text: String = str(msg.get("text", ""))
chat_log.append_text("[b]%s[/b]: %s\n" % [display_name, text])
func _send() -> void:
var text := msg_input.text.strip_edges()
if text.is_empty():
return
# NO optimistic echo. It was shown under a local name, and the poll then
# showed the real message a second time (KeetChat dedups only ids it has
# polled; the echo had none). Instead poll as soon as the daemon accepts the
# send, so our message appears once, under our keet name, one round trip later.
KeetService.chat.send(text, func(_result: Variant) -> void: KeetService.chat.poll_now())
msg_input.text = ""
# Release focus back to game after sending
msg_input.release_focus()