OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / packs / keet_chat / chat_overlay.gd

142 lines · gd ref:
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()