diff --git a/evennia/contrib/base_systems/godotwebsocket/README.md b/evennia/contrib/base_systems/godotwebsocket/README.md new file mode 100644 index 0000000000..b3f6abf99d --- /dev/null +++ b/evennia/contrib/base_systems/godotwebsocket/README.md @@ -0,0 +1,289 @@ +# Godot Websocket + +Contribution by ChrisLR, 2022 + +This contrib allows you to connect a Godot Client directly to your mud, +and display regular text with color in Godot's RichTextLabel using BBCode. +You can use Godot to provide advanced functionality with proper Evennia support. + + +## Installation + +You need to add the following settings in your settings.py and restart evennia. + +```python +PORTAL_SERVICES_PLUGIN_MODULES.append('evennia.contrib.base_systems.godotwebsocket.webclient') +GODOT_CLIENT_WEBSOCKET_PORT = 4008 +GODOT_CLIENT_WEBSOCKET_CLIENT_INTERFACE = "127.0.0.1" +``` + +This will make evennia listen on the port 4008 for Godot. +You can change the port and interface as you want. + + +## Usage + +The tl;dr of it is to connect using a Godot Websocket using the port defined above. +It will let you transfer data from Evennia to Godot, allowing you +to get styled text in a RichTextLabel with bbcode enabled or to handle +the extra data given from Evennia as needed. + + +This section assumes you have basic knowledge on how to use Godot. +You can read the following url for more details on Godot Websockets +and to implement a minimal client. + +https://docs.godotengine.org/en/stable/tutorials/networking/websocket.html + +The rest of this document will be for Godot 3, an example is left at the bottom +of this readme for Godot 4. + + +At the top of the file you must change the url to point at your mud. +``` +extends Node + +# The URL we will connect to +export var websocket_url = "ws://localhost:4008" + +``` + + +You must also remove the protocol from the `connect_to_url` call made +within the `_ready` function. + +``` +func _ready(): + # ... + # Change the following line from this + var err = _client.connect_to_url(websocket_url, ["lws-mirror-protocol"]) + # To this + var err = _client.connect_to_url(websocket_url) + # ... +``` + +This will allow you to connect to your mud. +After that you need to properly handle the data sent by evennia. +To do this, you should replace your `_on_data` method. +You will need to parse the JSON received to properly act on the data. +Here is an example +``` +func _on_data(): + # The following two lines will get us the data from Evennia. + var data = _client.get_peer(1).get_packet().get_string_from_utf8() + var json_data = JSON.parse(data).result + # The json_data is an array + + # The first element informs us this is simple text + # so we add it to the RichTextlabel + if json_data[0] == 'text': + for msg in json_data[1]: + label.append_bbcode(msg) + + # Always useful to print the data and see what we got. + print(data) +``` + +The first element is the type, it will be `text` if it is a message +It can be anything you would provide to the Evennia `msg` function. +The second element will be the data related to the type of message, in this case it is a list of text to display. +Since it is parsed BBCode, we can add that directly to a RichTextLabel by calling its append_bbcode method. + +If you want anything better than fancy text in Godot, you will have +to leverage Evennia's OOB to send extra data. + +You can [read more on OOB here](https://www.evennia.com/docs/latest/OOB.html#oob). + +In this example, we send coordinates whenever we message our character. + +Evennia +```python +caller.msg(coordinates=(9, 2)) +``` + +Godot +```gdscript +func _on_data(): + ... + if json_data[0] == 'text': + for msg in json_data[1]: + label.append_bbcode(msg) + + # Notice the first element is the name of the kwarg we used from evennia. + elif json_data[0] == 'coordinates': + var coords_data = json_data[2] + player.set_pos(coords_data) + + ... +``` + +A good idea would be to set up Godot Signals you can trigger based on the data +you receive, so you can manage the code better. + +## Known Issues + +- Sending SaverDicts and similar objects straight from Evennia .DB will cause issues, + cast them to dict() or list() before doing so. + +- Background colors are only supported by Godot 4. + +## Godot 3 Example + +This is an example of a Script to use in Godot 3. +The script can be attached to the root UI node. + +```gdscript +extends Node + +# The URL to connect to, should be your mud. +export var websocket_url = "ws://127.0.0.1:4008" + +# These are references to controls in the scene +onready var parent = get_parent() +onready var label = parent.get_node("%ChatLog") +onready var txtEdit = parent.get_node("%ChatInput") + +onready var room = get_node("/root/World/Room") + +# Our WebSocketClient instance +var _client = WebSocketClient.new() + +var is_connected = false + +func _ready(): + # Connect base signals to get notified of connection open, close, errors and messages + _client.connect("connection_closed", self, "_closed") + _client.connect("connection_error", self, "_closed") + _client.connect("connection_established", self, "_connected") + _client.connect("data_received", self, "_on_data") + print('Ready') + + # Initiate connection to the given URL. + var err = _client.connect_to_url(websocket_url) + if err != OK: + print("Unable to connect") + set_process(false) + +func _closed(was_clean = false): + # was_clean will tell you if the disconnection was correctly notified + # by the remote peer before closing the socket. + print("Closed, clean: ", was_clean) + set_process(false) + +func _connected(proto = ""): + is_connected = true + print("Connected with protocol: ", proto) + +func _on_data(): + # This is called when Godot receives data from evennia + var data = _client.get_peer(1).get_packet().get_string_from_utf8() + var json_data = JSON.parse(data).result + # Here we have the data from Evennia which is an array. + # The first element will be text if it is a message + # and would be the key of the OOB data you passed otherwise. + if json_data[0] == 'text': + # In this case, we simply append the data as bbcode to our label. + for msg in json_data[1]: + label.append_bbcode(msg) + elif json_data[0] == 'coordinates': + # Dummy signal emitted if we wanted to handle the new coordinates + # elsewhere in the project. + self.emit_signal('updated_coordinates', json_data[1]) + + + # We only print this for easier debugging. + print(data) + +func _process(delta): + # Required for websocket to properly react + _client.poll() + +func _on_button_send(): + # This is called when we press the button in the scene + # with a connected signal, it sends the written message to Evennia. + var msg = txtEdit.text + var msg_arr = ['text', [msg], {}] + var msg_str = JSON.print(msg_arr) + _client.get_peer(1).put_packet(msg_str.to_utf8()) + +func _notification(what): + # This is a special method that allows us to notify Evennia we are closing. + if what == MainLoop.NOTIFICATION_WM_QUIT_REQUEST: + if is_connected: + var msg_arr = ['text', ['quit'], {}] + var msg_str = JSON.print(msg_arr) + _client.get_peer(1).put_packet(msg_str.to_utf8()) + get_tree().quit() # default behavior + +``` + +## Godot 4 Example + +This is an example of a Script to use in Godot 4. +Note that the version is not final so the code may break. +It requires a WebSocketClientNode as a child of the root node. +The script can be attached to the root UI node. + +```gdscript +extends Control + +# The URL to connect to, should be your mud. +var websocket_url = "ws://127.0.0.1:4008" + +# These are references to controls in the scene +@onready +var label: RichTextLabel = get_node("%ChatLog") +@onready +var txtEdit: TextEdit = get_node("%ChatInput") +@onready +var websocket = get_node("WebSocketClient") + +func _ready(): + # We connect the various signals + websocket.connect('connected_to_server', self._connected) + websocket.connect('connection_closed', self._closed) + websocket.connect('message_received', self._on_data) + + # We attempt to connect and print out the error if we have one. + var result = websocket.connect_to_url(websocket_url) + if result != OK: + print('Could not connect:' + str(result)) + + +func _closed(): + # This emits if the connection was closed by the remote host or unexpectedly + print('Connection closed.') + set_process(false) + +func _connected(): + # This emits when the connection succeeds. + print('Connected!') + +func _on_data(data): + # This is called when Godot receives data from evennia + var json_data = JSON.parse_string(data) + # Here we have the data from Evennia which is an array. + # The first element will be text if it is a message + # and would be the key of the OOB data you passed otherwise. + if json_data[0] == 'text': + # In this case, we simply append the data as bbcode to our label. + for msg in json_data[1]: + # Here we include a newline at every message. + label.append_text("\n" + msg) + elif json_data[0] == 'coordinates': + # Dummy signal emitted if we wanted to handle the new coordinates + # elsewhere in the project. + self.emit_signal('updated_coordinates', json_data[1]) + + # We only print this for easier debugging. + print(data) + +func _on_button_pressed(): + # This is called when we press the button in the scene + # with a connected signal, it sends the written message to Evennia. + var msg = txtEdit.text + var msg_arr = ['text', [msg], {}] + var msg_str = JSON.stringify(msg_arr) + websocket.send(msg_str) + +``` \ No newline at end of file diff --git a/evennia/contrib/base_systems/godotwebsocket/__init__.py b/evennia/contrib/base_systems/godotwebsocket/__init__.py new file mode 100644 index 0000000000..c6b9827538 --- /dev/null +++ b/evennia/contrib/base_systems/godotwebsocket/__init__.py @@ -0,0 +1,14 @@ +""" +Godot Websocket - ChrisLR 2022 + +This provides parsing the ansi text to bbcode used by Godot for their RichTextLabel +and also provides the proper portal service to dedicate a port for Godot's Websockets. + +This allows you to connect both the regular webclient and a godot specific webclient. +You can simply connect the resulting text to Godot's RichTextLabel and have the proper display. +You could also pass extra data to this client for advanced functionality. + +See the docs for more information. +""" +from evennia.contrib.base_systems.godotwebsocket.text2bbcode import parse_to_bbcode, BBCODE_PARSER +from evennia.contrib.base_systems.godotwebsocket.webclient import GodotWebSocketClient diff --git a/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py b/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py new file mode 100644 index 0000000000..3b19245d6e --- /dev/null +++ b/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py @@ -0,0 +1,85 @@ +"""Tests for text2bbcode """ + +import mock +from django.test import TestCase + +from evennia.contrib.base_systems.godotwebsocket import text2bbcode +from evennia.utils import ansi + + +class TestText2Bbcode(TestCase): + def test_format_styles(self): + parser = text2bbcode.BBCODE_PARSER + self.assertEqual("foo", parser.format_styles("foo")) + self.assertEqual( + '[color=#800000]red[/color]foo', + parser.format_styles( + ansi.ANSI_UNHILITE + ansi.ANSI_RED + "red" + ansi.ANSI_NORMAL + "foo" + ), + ) + self.assertEqual( + '[bgcolor=#800000]red[/bgcolor]foo', + parser.format_styles(ansi.ANSI_BACK_RED + "red" + ansi.ANSI_NORMAL + "foo"), + ) + self.assertEqual( + '[bgcolor=#800000][color=#008000]red[/color][/bgcolor]foo', + parser.format_styles( + ansi.ANSI_BACK_RED + + ansi.ANSI_UNHILITE + + ansi.ANSI_GREEN + + "red" + + ansi.ANSI_NORMAL + + "foo" + ), + ) + self.assertEqual( + 'a [u]red[/u]foo', + parser.format_styles("a " + ansi.ANSI_UNDERLINE + "red" + ansi.ANSI_NORMAL + "foo"), + ) + self.assertEqual( + 'a [blink]red[/blink]foo', + parser.format_styles("a " + ansi.ANSI_BLINK + "red" + ansi.ANSI_NORMAL + "foo"), + ) + self.assertEqual( + 'a [bgcolor=#c0c0c0][color=#000000]red[/color][/bgcolor]foo', + parser.format_styles("a " + ansi.ANSI_INVERSE + "red" + ansi.ANSI_NORMAL + "foo"), + ) + + def test_convert_urls(self): + parser = text2bbcode.BBCODE_PARSER + self.assertEqual("foo", parser.convert_urls("foo")) + self.assertEqual( + 'a [url=http://redfoo]http://redfoo[/url] runs', + parser.convert_urls("a http://redfoo runs"), + ) + + def test_sub_mxp_links(self): + parser = text2bbcode.BBCODE_PARSER + mocked_match = mock.Mock() + mocked_match.groups.return_value = ["cmd", "text"] + + self.assertEqual("[mxp=send cmd=cmd]text[/mxp]", parser.sub_mxp_links(mocked_match)) + + def test_sub_text(self): + parser = text2bbcode.BBCODE_PARSER + + mocked_match = mock.Mock() + + mocked_match.groupdict.return_value = {"lineend": "foo"} + self.assertEqual("\n", parser.sub_text(mocked_match)) + + + def test_parse_bbcode(self): + self.assertEqual("foo", text2bbcode.parse_to_bbcode("foo")) + self.maxDiff = None + self.assertEqual( + text2bbcode.parse_to_bbcode("|^|[CHello|n|u|rW|go|yr|bl|md|c!|[G!"), + '[blink][bgcolor=#008080]Hello[/bgcolor][/blink]' + '[u][color=#ff0000]W[/color][/u]' + '[u][color=#00ff00]o[/color][/u]' + '[u][color=#ffff00]r[/color][/u]' + '[u][color=#0000ff]l[/color][/u]' + '[u][color=#ff00ff]d[/color][/u]' + '[u][color=#00ffff]![/color][/u]' + '[u][bgcolor=#008000][color=#00ffff]![/color][/bgcolor][/u]', + ) diff --git a/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py b/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py new file mode 100644 index 0000000000..b5a748b30d --- /dev/null +++ b/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py @@ -0,0 +1,592 @@ +""" +Godot Websocket - ChrisLR 2022 + +This file contains the necessary code and data to convert text with color tags to bbcode (For godot) +""" +from evennia.utils.ansi import * +from evennia.utils.text2html import TextToHTMLparser + +# All xterm256 RGB equivalents + +XTERM256_FG = "\033[38;5;{}m" +XTERM256_BG = "\033[48;5;{}m" + +COLOR_INDICE_TO_HEX = { + 'color-000': '#000000', 'color-001': '#800000', 'color-002': '#008000', 'color-003': '#808000', + 'color-004': '#000080', 'color-005': '#800080', 'color-006': '#008080', 'color-007': '#c0c0c0', + 'color-008': '#808080', 'color-009': '#ff0000', 'color-010': '#00ff00', 'color-011': '#ffff00', + 'color-012': '#0000ff', 'color-013': '#ff00ff', 'color-014': '#00ffff', 'color-015': '#ffffff', + 'color-016': '#000000', 'color-017': '#00005f', 'color-018': '#000087', 'color-019': '#0000af', + 'color-020': '#0000df', 'color-021': '#0000ff', 'color-022': '#005f00', 'color-023': '#005f5f', + 'color-024': '#005f87', 'color-025': '#005faf', 'color-026': '#005fdf', 'color-027': '#005fff', + 'color-028': '#008700', 'color-029': '#00875f', 'color-030': '#008787', 'color-031': '#0087af', + 'color-032': '#0087df', 'color-033': '#0087ff', 'color-034': '#00af00', 'color-035': '#00af5f', + 'color-036': '#00af87', 'color-037': '#00afaf', 'color-038': '#00afdf', 'color-039': '#00afff', + 'color-040': '#00df00', 'color-041': '#00df5f', 'color-042': '#00df87', 'color-043': '#00dfaf', + 'color-044': '#00dfdf', 'color-045': '#00dfff', 'color-046': '#00ff00', 'color-047': '#00ff5f', + 'color-048': '#00ff87', 'color-049': '#00ffaf', 'color-050': '#00ffdf', 'color-051': '#00ffff', + 'color-052': '#5f0000', 'color-053': '#5f005f', 'color-054': '#5f0087', 'color-055': '#5f00af', + 'color-056': '#5f00df', 'color-057': '#5f00ff', 'color-058': '#5f5f00', 'color-059': '#5f5f5f', + 'color-060': '#5f5f87', 'color-061': '#5f5faf', 'color-062': '#5f5fdf', 'color-063': '#5f5fff', + 'color-064': '#5f8700', 'color-065': '#5f875f', 'color-066': '#5f8787', 'color-067': '#5f87af', + 'color-068': '#5f87df', 'color-069': '#5f87ff', 'color-070': '#5faf00', 'color-071': '#5faf5f', + 'color-072': '#5faf87', 'color-073': '#5fafaf', 'color-074': '#5fafdf', 'color-075': '#5fafff', + 'color-076': '#5fdf00', 'color-077': '#5fdf5f', 'color-078': '#5fdf87', 'color-079': '#5fdfaf', + 'color-080': '#5fdfdf', 'color-081': '#5fdfff', 'color-082': '#5fff00', 'color-083': '#5fff5f', + 'color-084': '#5fff87', 'color-085': '#5fffaf', 'color-086': '#5fffdf', 'color-087': '#5fffff', + 'color-088': '#870000', 'color-089': '#87005f', 'color-090': '#870087', 'color-091': '#8700af', + 'color-092': '#8700df', 'color-093': '#8700ff', 'color-094': '#875f00', 'color-095': '#875f5f', + 'color-096': '#875f87', 'color-097': '#875faf', 'color-098': '#875fdf', 'color-099': '#875fff', + 'color-100': '#878700', 'color-101': '#87875f', 'color-102': '#878787', 'color-103': '#8787af', + 'color-104': '#8787df', 'color-105': '#8787ff', 'color-106': '#87af00', 'color-107': '#87af5f', + 'color-108': '#87af87', 'color-109': '#87afaf', 'color-110': '#87afdf', 'color-111': '#87afff', + 'color-112': '#87df00', 'color-113': '#87df5f', 'color-114': '#87df87', 'color-115': '#87dfaf', + 'color-116': '#87dfdf', 'color-117': '#87dfff', 'color-118': '#87ff00', 'color-119': '#87ff5f', + 'color-120': '#87ff87', 'color-121': '#87ffaf', 'color-122': '#87ffdf', 'color-123': '#87ffff', + 'color-124': '#af0000', 'color-125': '#af005f', 'color-126': '#af0087', 'color-127': '#af00af', + 'color-128': '#af00df', 'color-129': '#af00ff', 'color-130': '#af5f00', 'color-131': '#af5f5f', + 'color-132': '#af5f87', 'color-133': '#af5faf', 'color-134': '#af5fdf', 'color-135': '#af5fff', + 'color-136': '#af8700', 'color-137': '#af875f', 'color-138': '#af8787', 'color-139': '#af87af', + 'color-140': '#af87df', 'color-141': '#af87ff', 'color-142': '#afaf00', 'color-143': '#afaf5f', + 'color-144': '#afaf87', 'color-145': '#afafaf', 'color-146': '#afafdf', 'color-147': '#afafff', + 'color-148': '#afdf00', 'color-149': '#afdf5f', 'color-150': '#afdf87', 'color-151': '#afdfaf', + 'color-152': '#afdfdf', 'color-153': '#afdfff', 'color-154': '#afff00', 'color-155': '#afff5f', + 'color-156': '#afff87', 'color-157': '#afffaf', 'color-158': '#afffdf', 'color-159': '#afffff', + 'color-160': '#df0000', 'color-161': '#df005f', 'color-162': '#df0087', 'color-163': '#df00af', + 'color-164': '#df00df', 'color-165': '#df00ff', 'color-166': '#df5f00', 'color-167': '#df5f5f', + 'color-168': '#df5f87', 'color-169': '#df5faf', 'color-170': '#df5fdf', 'color-171': '#df5fff', + 'color-172': '#df8700', 'color-173': '#df875f', 'color-174': '#df8787', 'color-175': '#df87af', + 'color-176': '#df87df', 'color-177': '#df87ff', 'color-178': '#dfaf00', 'color-179': '#dfaf5f', + 'color-180': '#dfaf87', 'color-181': '#dfafaf', 'color-182': '#dfafdf', 'color-183': '#dfafff', + 'color-184': '#dfdf00', 'color-185': '#dfdf5f', 'color-186': '#dfdf87', 'color-187': '#dfdfaf', + 'color-188': '#dfdfdf', 'color-189': '#dfdfff', 'color-190': '#dfff00', 'color-191': '#dfff5f', + 'color-192': '#dfff87', 'color-193': '#dfffaf', 'color-194': '#dfffdf', 'color-195': '#dfffff', + 'color-196': '#ff0000', 'color-197': '#ff005f', 'color-198': '#ff0087', 'color-199': '#ff00af', + 'color-200': '#ff00df', 'color-201': '#ff00ff', 'color-202': '#ff5f00', 'color-203': '#ff5f5f', + 'color-204': '#ff5f87', 'color-205': '#ff5faf', 'color-206': '#ff5fdf', 'color-207': '#ff5fff', + 'color-208': '#ff8700', 'color-209': '#ff875f', 'color-210': '#ff8787', 'color-211': '#ff87af', + 'color-212': '#ff87df', 'color-213': '#ff87ff', 'color-214': '#ffaf00', 'color-215': '#ffaf5f', + 'color-216': '#ffaf87', 'color-217': '#ffafaf', 'color-218': '#ffafdf', 'color-219': '#ffafff', + 'color-220': '#ffdf00', 'color-221': '#ffdf5f', 'color-222': '#ffdf87', 'color-223': '#ffdfaf', + 'color-224': '#ffdfdf', 'color-225': '#ffdfff', 'color-226': '#ffff00', 'color-227': '#ffff5f', + 'color-228': '#ffff87', 'color-229': '#ffffaf', 'color-230': '#ffffdf', 'color-231': '#ffffff', + 'color-232': '#080808', 'color-233': '#121212', 'color-234': '#1c1c1c', 'color-235': '#262626', + 'color-236': '#303030', 'color-237': '#3a3a3a', 'color-238': '#444444', 'color-239': '#4e4e4e', + 'color-240': '#585858', 'color-241': '#606060', 'color-242': '#666666', 'color-243': '#767676', + 'color-244': '#808080', 'color-245': '#8a8a8a', 'color-246': '#949494', 'color-247': '#9e9e9e', + 'color-248': '#a8a8a8', 'color-249': '#b2b2b2', 'color-250': '#bcbcbc', 'color-251': '#c6c6c6', + 'color-252': '#d0d0d0', 'color-253': '#dadada', 'color-254': '#e4e4e4', 'color-255': '#eeeeee', + 'bgcolor-000': '#000000', 'bgcolor-001': '#800000', 'bgcolor-002': '#008000', + 'bgcolor-003': '#808000', 'bgcolor-004': '#000080', 'bgcolor-005': '#800080', + 'bgcolor-006': '#008080', 'bgcolor-007': '#c0c0c0', 'bgcolor-008': '#808080', + 'bgcolor-009': '#ff0000', 'bgcolor-010': '#00ff00', 'bgcolor-011': '#ffff00', + 'bgcolor-012': '#0000ff', 'bgcolor-013': '#ff00ff', 'bgcolor-014': '#00ffff', + 'bgcolor-015': '#ffffff', 'bgcolor-016': '#000000', 'bgcolor-017': '#00005f', + 'bgcolor-018': '#000087', 'bgcolor-019': '#0000af', 'bgcolor-020': '#0000df', + 'bgcolor-021': '#0000ff', 'bgcolor-022': '#005f00', 'bgcolor-023': '#005f5f', + 'bgcolor-024': '#005f87', 'bgcolor-025': '#005faf', 'bgcolor-026': '#005fdf', + 'bgcolor-027': '#005fff', 'bgcolor-028': '#008700', 'bgcolor-029': '#00875f', + 'bgcolor-030': '#008787', 'bgcolor-031': '#0087af', 'bgcolor-032': '#0087df', + 'bgcolor-033': '#0087ff', 'bgcolor-034': '#00af00', 'bgcolor-035': '#00af5f', + 'bgcolor-036': '#00af87', 'bgcolor-037': '#00afaf', 'bgcolor-038': '#00afdf', + 'bgcolor-039': '#00afff', 'bgcolor-040': '#00df00', 'bgcolor-041': '#00df5f', + 'bgcolor-042': '#00df87', 'bgcolor-043': '#00dfaf', 'bgcolor-044': '#00dfdf', + 'bgcolor-045': '#00dfff', 'bgcolor-046': '#00ff00', 'bgcolor-047': '#00ff5f', + 'bgcolor-048': '#00ff87', 'bgcolor-049': '#00ffaf', 'bgcolor-050': '#00ffdf', + 'bgcolor-051': '#00ffff', 'bgcolor-052': '#5f0000', 'bgcolor-053': '#5f005f', + 'bgcolor-054': '#5f0087', 'bgcolor-055': '#5f00af', 'bgcolor-056': '#5f00df', + 'bgcolor-057': '#5f00ff', 'bgcolor-058': '#5f5f00', 'bgcolor-059': '#5f5f5f', + 'bgcolor-060': '#5f5f87', 'bgcolor-061': '#5f5faf', 'bgcolor-062': '#5f5fdf', + 'bgcolor-063': '#5f5fff', 'bgcolor-064': '#5f8700', 'bgcolor-065': '#5f875f', + 'bgcolor-066': '#5f8787', 'bgcolor-067': '#5f87af', 'bgcolor-068': '#5f87df', + 'bgcolor-069': '#5f87ff', 'bgcolor-070': '#5faf00', 'bgcolor-071': '#5faf5f', + 'bgcolor-072': '#5faf87', 'bgcolor-073': '#5fafaf', 'bgcolor-074': '#5fafdf', + 'bgcolor-075': '#5fafff', 'bgcolor-076': '#5fdf00', 'bgcolor-077': '#5fdf5f', + 'bgcolor-078': '#5fdf87', 'bgcolor-079': '#5fdfaf', 'bgcolor-080': '#5fdfdf', + 'bgcolor-081': '#5fdfff', 'bgcolor-082': '#5fff00', 'bgcolor-083': '#5fff5f', + 'bgcolor-084': '#5fff87', 'bgcolor-085': '#5fffaf', 'bgcolor-086': '#5fffdf', + 'bgcolor-087': '#5fffff', 'bgcolor-088': '#870000', 'bgcolor-089': '#87005f', + 'bgcolor-090': '#870087', 'bgcolor-091': '#8700af', 'bgcolor-092': '#8700df', + 'bgcolor-093': '#8700ff', 'bgcolor-094': '#875f00', 'bgcolor-095': '#875f5f', + 'bgcolor-096': '#875f87', 'bgcolor-097': '#875faf', 'bgcolor-098': '#875fdf', + 'bgcolor-099': '#875fff', 'bgcolor-100': '#878700', 'bgcolor-101': '#87875f', + 'bgcolor-102': '#878787', 'bgcolor-103': '#8787af', 'bgcolor-104': '#8787df', + 'bgcolor-105': '#8787ff', 'bgcolor-106': '#87af00', 'bgcolor-107': '#87af5f', + 'bgcolor-108': '#87af87', 'bgcolor-109': '#87afaf', 'bgcolor-110': '#87afdf', + 'bgcolor-111': '#87afff', 'bgcolor-112': '#87df00', 'bgcolor-113': '#87df5f', + 'bgcolor-114': '#87df87', 'bgcolor-115': '#87dfaf', 'bgcolor-116': '#87dfdf', + 'bgcolor-117': '#87dfff', 'bgcolor-118': '#87ff00', 'bgcolor-119': '#87ff5f', + 'bgcolor-120': '#87ff87', 'bgcolor-121': '#87ffaf', 'bgcolor-122': '#87ffdf', + 'bgcolor-123': '#87ffff', 'bgcolor-124': '#af0000', 'bgcolor-125': '#af005f', + 'bgcolor-126': '#af0087', 'bgcolor-127': '#af00af', 'bgcolor-128': '#af00df', + 'bgcolor-129': '#af00ff', 'bgcolor-130': '#af5f00', 'bgcolor-131': '#af5f5f', + 'bgcolor-132': '#af5f87', 'bgcolor-133': '#af5faf', 'bgcolor-134': '#af5fdf', + 'bgcolor-135': '#af5fff', 'bgcolor-136': '#af8700', 'bgcolor-137': '#af875f', + 'bgcolor-138': '#af8787', 'bgcolor-139': '#af87af', 'bgcolor-140': '#af87df', + 'bgcolor-141': '#af87ff', 'bgcolor-142': '#afaf00', 'bgcolor-143': '#afaf5f', + 'bgcolor-144': '#afaf87', 'bgcolor-145': '#afafaf', 'bgcolor-146': '#afafdf', + 'bgcolor-147': '#afafff', 'bgcolor-148': '#afdf00', 'bgcolor-149': '#afdf5f', + 'bgcolor-150': '#afdf87', 'bgcolor-151': '#afdfaf', 'bgcolor-152': '#afdfdf', + 'bgcolor-153': '#afdfff', 'bgcolor-154': '#afff00', 'bgcolor-155': '#afff5f', + 'bgcolor-156': '#afff87', 'bgcolor-157': '#afffaf', 'bgcolor-158': '#afffdf', + 'bgcolor-159': '#afffff', 'bgcolor-160': '#df0000', 'bgcolor-161': '#df005f', + 'bgcolor-162': '#df0087', 'bgcolor-163': '#df00af', 'bgcolor-164': '#df00df', + 'bgcolor-165': '#df00ff', 'bgcolor-166': '#df5f00', 'bgcolor-167': '#df5f5f', + 'bgcolor-168': '#df5f87', 'bgcolor-169': '#df5faf', 'bgcolor-170': '#df5fdf', + 'bgcolor-171': '#df5fff', 'bgcolor-172': '#df8700', 'bgcolor-173': '#df875f', + 'bgcolor-174': '#df8787', 'bgcolor-175': '#df87af', 'bgcolor-176': '#df87df', + 'bgcolor-177': '#df87ff', 'bgcolor-178': '#dfaf00', 'bgcolor-179': '#dfaf5f', + 'bgcolor-180': '#dfaf87', 'bgcolor-181': '#dfafaf', 'bgcolor-182': '#dfafdf', + 'bgcolor-183': '#dfafff', 'bgcolor-184': '#dfdf00', 'bgcolor-185': '#dfdf5f', + 'bgcolor-186': '#dfdf87', 'bgcolor-187': '#dfdfaf', 'bgcolor-188': '#dfdfdf', + 'bgcolor-189': '#dfdfff', 'bgcolor-190': '#dfff00', 'bgcolor-191': '#dfff5f', + 'bgcolor-192': '#dfff87', 'bgcolor-193': '#dfffaf', 'bgcolor-194': '#dfffdf', + 'bgcolor-195': '#dfffff', 'bgcolor-196': '#ff0000', 'bgcolor-197': '#ff005f', + 'bgcolor-198': '#ff0087', 'bgcolor-199': '#ff00af', 'bgcolor-200': '#ff00df', + 'bgcolor-201': '#ff00ff', 'bgcolor-202': '#ff5f00', 'bgcolor-203': '#ff5f5f', + 'bgcolor-204': '#ff5f87', 'bgcolor-205': '#ff5faf', 'bgcolor-206': '#ff5fdf', + 'bgcolor-207': '#ff5fff', 'bgcolor-208': '#ff8700', 'bgcolor-209': '#ff875f', + 'bgcolor-210': '#ff8787', 'bgcolor-211': '#ff87af', 'bgcolor-212': '#ff87df', + 'bgcolor-213': '#ff87ff', 'bgcolor-214': '#ffaf00', 'bgcolor-215': '#ffaf5f', + 'bgcolor-216': '#ffaf87', 'bgcolor-217': '#ffafaf', 'bgcolor-218': '#ffafdf', + 'bgcolor-219': '#ffafff', 'bgcolor-220': '#ffdf00', 'bgcolor-221': '#ffdf5f', + 'bgcolor-222': '#ffdf87', 'bgcolor-223': '#ffdfaf', 'bgcolor-224': '#ffdfdf', + 'bgcolor-225': '#ffdfff', 'bgcolor-226': '#ffff00', 'bgcolor-227': '#ffff5f', + 'bgcolor-228': '#ffff87', 'bgcolor-229': '#ffffaf', 'bgcolor-230': '#ffffdf', + 'bgcolor-231': '#ffffff', 'bgcolor-232': '#080808', 'bgcolor-233': '#121212', + 'bgcolor-234': '#1c1c1c', 'bgcolor-235': '#262626', 'bgcolor-236': '#303030', + 'bgcolor-237': '#3a3a3a', 'bgcolor-238': '#444444', 'bgcolor-239': '#4e4e4e', + 'bgcolor-240': '#585858', 'bgcolor-241': '#606060', 'bgcolor-242': '#666666', + 'bgcolor-243': '#767676', 'bgcolor-244': '#808080', 'bgcolor-245': '#8a8a8a', + 'bgcolor-246': '#949494', 'bgcolor-247': '#9e9e9e', 'bgcolor-248': '#a8a8a8', + 'bgcolor-249': '#b2b2b2', 'bgcolor-250': '#bcbcbc', 'bgcolor-251': '#c6c6c6', + 'bgcolor-252': '#d0d0d0', 'bgcolor-253': '#dadada', 'bgcolor-254': '#e4e4e4', + 'bgcolor-255': '#eeeeee' +} + + +""" +The classes below exist to properly encapsulate text and other tag classes +because the order of how tags are opened and closed are important to display in godot. +""" + + +class RootTag: + """ + The Root tag class made to contain other tags. + """ + + __slots__ = ('child',) + + def __init__(self): + self.child = None + + def __str__(self): + return str(self.child) if self.child else "" + + +class ChildTag: + """ + A node made to be contained. + """ + + def __init__(self, parent): + self.parent = parent + if parent: + parent.child = self + + def set_parent(self, parent): + self.parent = parent + if parent: + parent.child = self + + +class TextTag(ChildTag): + """ + A BBCodeTag node to output regular text. + Output: SomeText + """ + + __slots__ = ('parent', 'child', 'text') + + def __init__(self, parent, text): + super().__init__(parent) + self.text = text + self.child = None + + def __str__(self): + return f"{self.text}{self.child or ''}" + + +class BBCodeTag(ChildTag): + """ + Base BBCodeTag node to encapsulate and be encapsulated. + """ + + __slots__ = ('parent', 'child',) + + code = '' + + def __init__(self, parent): + super().__init__(parent) + self.child = None + + def __str__(self): + return f"[{self.code}]{self.child or ''}[/{self.code}]" + + +class UnderlineTag(BBCodeTag): + """ + A BBCodeTag node for underlined text. + Output: [u]Underlined Text[/u] + """ + + code = 'u' + + +class BlinkTag(BBCodeTag): + """ + A BBCodeTag node for blinking text. + Output: [blink]Blinking Text[/blink] + """ + + code = 'blink' + + +class ColorTag(BBCodeTag): + """ + A BBCodeTag node for foreground color. + Output: [fgcolor=#000000]Colorized Text[/fgcolor] + """ + + __slots__ = ('parent', 'child', 'color_hex',) + + code = 'color' + + def __init__(self, parent, color_hex): + super().__init__(parent) + self.color_hex = color_hex + + def __str__(self): + return f"[{self.code}={self.color_hex}]{self.child or ''}[/{self.code}]" + + +class BGColorTag(ColorTag): + """ + A BBCodeTag node for background color. + Output: [bgcolor=#000000]Colorized Text[/bgcolor] + """ + + code = 'bgcolor' + + +class UrlTag(BBCodeTag): + """ + A BBCodeTag node used for urls. + Output: [url=www.example.com]Child Text[/url] + + """ + + __slots__ = ('parent', 'child', 'url_data',) + + code = 'url' + + def __init__(self, parent, url_data=''): + super().__init__(parent) + self.url_data = url_data + + def __str__(self): + return f"[{self.code}={self.url_data}]{self.child or ''}[/{self.code}]" + + +class TextToBBCODEparser(TextToHTMLparser): + """ + This class describes a parser for converting from ANSI to BBCode. + It inherits from the TextToHTMLParser and overrides the specifics for bbcode. + """ + + def convert_urls(self, text): + """ + Converts urls within text to bbcode style + + Args: + text (str): Text to parse + + Returns: + text (str): Processed text + """ + # Converts to bbcode styled urls + return self.re_url.sub(r'[url=\1]\1[/url]\2', text) + + def sub_mxp_links(self, match): + """ + Helper method to be passed to re.sub, + replaces MXP links with bbcode. + + Args: + match (re.Matchobject): Match for substitution. + + Returns: + text (str): Processed text. + + """ + cmd, text = [grp.replace('"', "\\"") for grp in match.groups()] + val = f"[mxp=send cmd={cmd}]{text}[/mxp]" + + return val + + def sub_mxp_urls(self, match): + """ + Helper method to be passed to re.sub, + replaces MXP links with bbcode. + Args: + match (re.Matchobject): Match for substitution. + Returns: + text (str): Processed text. + """ + + url, text = [grp.replace('"', "\\"") for grp in match.groups()] + val = f"[url={url}]{text}[/url]" + + return val + + def sub_text(self, match): + """ + Helper method to be passed to re.sub, + for handling all substitutions. + + Args: + match (re.Matchobject): Match for substitution. + + Returns: + text (str): Processed text. + + """ + cdict = match.groupdict() + if cdict["lineend"]: + return "\n" + + return None + + def format_styles(self, text): + """ + Takes a string with parsed ANSI codes and replaces them with bbcode style tags + + Args: + text (str): The string to process. + + Returns: + text (str): Processed text. + """ + + # split out the ANSI codes and clean out any empty items + str_list = [substr for substr in self.re_style.split(text) if substr] + + inverse = False + # default color is light grey - unhilite + white + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + # default bg is black + bg = ANSI_BACK_BLACK + previous_fg = None + previous_bg = None + blink = False + underline = False + new_style = False + + parts = [] + root_tag = RootTag() + current_tag = root_tag + + for i, substr in enumerate(str_list): + # reset all current styling + if substr == ANSI_NORMAL: + # close any existing span if necessary + parts.append(str(root_tag)) + root_tag = RootTag() + current_tag = root_tag + # reset to defaults + inverse = False + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + bg = ANSI_BACK_BLACK + previous_fg = None + previous_bg = None + blink = False + underline = False + new_style = False + + # change color + elif substr in self.ansi_color_codes + self.xterm_fg_codes: + # set new color + fg = substr + new_style = True + + # change bg color + elif substr in self.ansi_bg_codes + self.xterm_bg_codes: + # set new bg + bg = substr + new_style = True + + # non-color codes + elif substr in self.style_codes: + new_style = True + + # hilight codes + if substr in (ANSI_HILITE, ANSI_UNHILITE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + # set new hilight status + hilight = ANSI_UNHILITE if substr == ANSI_UNHILITE else ANSI_HILITE + + # inversion codes + if substr in (ANSI_INVERSE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + inverse = True + + # blink codes + if ( + substr in (ANSI_BLINK, ANSI_BLINK_HILITE, ANSI_INV_BLINK_HILITE) + and not blink + ): + blink = True + current_tag = BlinkTag(current_tag) + + # underline + if substr == ANSI_UNDERLINE and not underline: + underline = True + current_tag = UnderlineTag(current_tag) + + else: + close_tags = False + color_tag = None + bgcolor_tag = None + # normal text, add text back to list + if new_style: + # prior entry was cleared, which means style change + # get indices for the fg and bg codes + bg_index = self.bglist.index(bg) + try: + color_index = self.colorlist.index(hilight + fg) + except ValueError: + # xterm256 colors don't have the hilight codes + color_index = self.colorlist.index(fg) + + if inverse: + # inverse means swap fg and bg indices + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + else: + # use fg and bg indices for classes + bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0")) + color_class = "color-{}".format(str(color_index).rjust(3, "0")) + + # black bg is the default, don't explicitly style + if bg_class != "bgcolor-000": + color_hex = COLOR_INDICE_TO_HEX.get(bg_class) + bgcolor_tag = BGColorTag(None, color_hex=color_hex) + if previous_bg and previous_bg != color_hex: + close_tags = True + else: + previous_bg = color_hex + + # light grey text is the default, don't explicitly style + if color_class != "color-007": + color_hex = COLOR_INDICE_TO_HEX.get(color_class) + color_tag = ColorTag(None, color_hex=color_hex) + if previous_fg and previous_fg != color_hex: + close_tags = True + else: + previous_fg = color_hex + + new_tag = TextTag(None, substr) + if close_tags: + # Because the order is important, we need to close the tags and reopen those who shouldn't reset. + new_style = False + parts.append(str(root_tag)) + root_tag = RootTag() + current_tag = root_tag + if blink: + current_tag = BlinkTag(current_tag) + + if underline: + current_tag = UnderlineTag(current_tag) + + if bgcolor_tag: + bgcolor_tag.set_parent(current_tag) + current_tag = bgcolor_tag + + if color_tag: + color_tag.set_parent(current_tag) + current_tag = color_tag + + new_tag.set_parent(current_tag) + current_tag = new_tag + else: + if bgcolor_tag: + bgcolor_tag.set_parent(current_tag) + current_tag = bgcolor_tag + + if color_tag: + color_tag.set_parent(current_tag) + current_tag = color_tag + + new_tag.set_parent(current_tag) + current_tag = new_tag + + any_text = self._get_text_tag(root_tag) + if any_text: + # Only append tags if text was added. + last_part = str(root_tag) + parts.append(last_part) + + # recombine back into string + return "".join(parts) + + def _get_text_tag(self, root): + child = root.child + while child: + if isinstance(child, TextTag): + return child + else: + child = child.child + + return None + + def parse(self, text, strip_ansi=False): + """ + Main access function, converts a text containing ANSI codes + into html statements. + + Args: + text (str): Text to process. + strip_ansi (bool, optional): + + Returns: + text (str): Parsed text. + + """ + # parse everything to ansi first + text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True) + # convert all ansi to html + result = re.sub(self.re_string, self.sub_text, text) + result = re.sub(self.re_mxplink, self.sub_mxp_links, result) + result = re.sub(self.re_mxpurl, self.sub_mxp_urls, result) + result = self.remove_bells(result) + result = self.format_styles(result) + result = self.remove_backspaces(result) + result = self.convert_urls(result) + + return result + + +BBCODE_PARSER = TextToBBCODEparser() + + +# +# Access function +# + + +def parse_to_bbcode(string, strip_ansi=False, parser=BBCODE_PARSER): + """ + Parses a string, replace ANSI markup with bbcode + """ + return parser.parse(string, strip_ansi=strip_ansi) diff --git a/evennia/contrib/base_systems/godotwebsocket/webclient.py b/evennia/contrib/base_systems/godotwebsocket/webclient.py new file mode 100644 index 0000000000..ceb3ee4679 --- /dev/null +++ b/evennia/contrib/base_systems/godotwebsocket/webclient.py @@ -0,0 +1,79 @@ +""" +Godot Websocket - ChrisLR 2022 + +This file contains the code necessary to dedicate a port to communicate with Godot via Websockets. +It uses the plugin system and should be plugged via settings as detailed in the readme. +""" +import json + +from autobahn.twisted import WebSocketServerFactory +from twisted.application import internet + +from evennia import settings +from evennia.contrib.base_systems.godotwebsocket.text2bbcode import parse_to_bbcode +from evennia.server.portal import webclient +from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS +from evennia.settings_default import LOCKDOWN_MODE + + +class GodotWebSocketClient(webclient.WebSocketClient): + """ + Implements the server-side of the Websocket connection specific to Godot. + It inherits from the basic Websocket implementation and changes only what is necessary. + + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.protocol_key = "godotclient/websocket" + + def send_text(self, *args, **kwargs): + """ + Send text data. This will pre-process the text for + color-replacement, conversion to bbcode etc. + + Args: + text (str): Text to send. + + Keyword Args: + options (dict): Options-dict with the following keys understood: + - nocolor (bool): Clean out all color. + - send_prompt (bool): Send a prompt with parsed bbcode + + """ + if args: + args = list(args) + text = args[0] + if text is None: + return + else: + return + + flags = self.protocol_flags + + options = kwargs.pop("options", {}) + nocolor = options.get("nocolor", flags.get("NOCOLOR", False)) + prompt = options.get("send_prompt", False) + + cmd = "prompt" if prompt else "text" + args[0] = parse_to_bbcode(text, strip_ansi=nocolor) + + # send to client on required form [cmdname, args, kwargs] + self.sendLine(json.dumps([cmd, args, kwargs])) + + +def start_plugin_services(portal): + class GodotWebsocket(WebSocketServerFactory): + "Only here for better naming in logs" + pass + + factory = GodotWebsocket() + factory.noisy = False + factory.protocol = GodotWebSocketClient + factory.sessionhandler = PORTAL_SESSIONS + + interface = "127.0.0.1" if LOCKDOWN_MODE else settings.GODOT_CLIENT_WEBSOCKET_CLIENT_INTERFACE + + port = settings.GODOT_CLIENT_WEBSOCKET_PORT + websocket_service = internet.TCPServer(port, factory, interface=interface) + websocket_service.setName("GodotWebSocket%s:%s" % (interface, port)) + portal.services.addService(websocket_service)