Merge branch 'master' of https://git.socklab.moe/chimchooree/blessfrey-bottle
@ -1 +1 @@
|
|||||||
<p>All acts of god are attributed to him. His steed is the MessageBus. He is technically an entity. <br></p>
|
<p>All acts of god are attributed to him. His steed is the MessageBus. He is a God, technically a base-based entity. His job is Mailman/Mailman. </p>
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
<p>Works like a skill in most instances, but it has a persistent effect that is triggered instead of activated. </p>
|
||||||
|
|
||||||
|
<p>Usually, there's going to be some Area2D (covers the lava area, etc) that will cast the skill upon collision with a tangible and end the skill when the tangible exits. </p>
|
@ -0,0 +1,33 @@
|
|||||||
|
<h2>description </h2>
|
||||||
|
<p>Passive skills bestow a constant effect and are perpetually applied in the background. </p>
|
||||||
|
|
||||||
|
<h2>flow </h2>
|
||||||
|
<ul>
|
||||||
|
<li>Skill button pressed </li>
|
||||||
|
<li>Used
|
||||||
|
<ul>
|
||||||
|
<li>New usage </li>
|
||||||
|
<li>Target </li>
|
||||||
|
<li>Fail if user activating a skill </li>
|
||||||
|
<li>Fail if skill cooling down </li>
|
||||||
|
<li>Cooldown </li>
|
||||||
|
<li>Publish & emit "skill_used" </li>
|
||||||
|
<li>Pay costs </li>
|
||||||
|
<li>Append to active list </li>
|
||||||
|
<li>Activate </li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Skills perform their function on their user and target, but they are also applied to their target's <code>applied_skills</code>, a list of skill usages. </p>
|
||||||
|
|
||||||
|
<h2>structure </h2>
|
||||||
|
<p></p>
|
||||||
|
|
||||||
|
<h2>logic </h2>
|
||||||
|
<h3>end </h3>
|
||||||
|
<p>A skill naturally ends after completion. After all the keywords are complete, the skill will complete. </p>
|
||||||
|
|
||||||
|
<p>A skill prematurely ends if it is removed (the remove keyword targets applied skills) or reapplied (a duplicate skill ends earlier instances of the skill then is applied normally). </p>
|
||||||
|
|
||||||
|
<p>When a skill ends for any reason, all keywords must also end. </p>
|
@ -1,3 +1,4 @@
|
|||||||
<p><a href="/blessfrey-gdd/job">Jobs</a> and <a href="/blessfrey-gdd/gig">gigs</a> have <a href="/blessfrey-gdd/attribute">attributes</a> associated with them, while only jobs have <a href="/blessfrey-gdd/perk">perks</a>. Each attribute defines a specialization within the job's overall playstyle. A perk is the same as an attribute, but characters only have access to their job's perk, not their gig's perk. </p>
|
<p><a href="/blessfrey-gdd/job">Jobs</a> and <a href="/blessfrey-gdd/gig">gigs</a> have <a href="/blessfrey-gdd/attribute">attributes</a> associated with them, while only jobs have <a href="/blessfrey-gdd/perk">perks</a>. Each attribute defines a specialization within the job's overall playstyle. A perk is the same as an attribute, but characters only have access to their job's perk, not their gig's perk. </p>
|
||||||
<p>Perks bestow a constant effect on the character. For instance, the Brawler's Critical Eye increases his critical hit rate.</p>
|
<p>Perks bestow a constant effect on the character. For instance, the Brawler's Critical Eye increases his critical hit rate.</p>
|
||||||
<p>Each perk and attribute is associated with a set of skills. You can increase and decrease their values in safe areas (no permanent decision), and their skills' effectiveness with scale with their attributes. </p>
|
<p>Each perk and attribute is associated with a set of skills. You can increase and decrease their values in safe areas (no permanent decision), and their skills' effectiveness with scale with their attributes. </p>
|
||||||
|
<p>Perks are activated at _ready or job change onto their owner. </p>
|
||||||
|
@ -0,0 +1,694 @@
|
|||||||
|
<!---->
|
||||||
|
<h1>Godot Tutorial: Chat Room using WebSocket </h1>
|
||||||
|
august 31, 2022<br>
|
||||||
|
#webdev <br>
|
||||||
|
<br>
|
||||||
|
<p>Follow along to make your first mini WebSocket application in GDScript (with a little JSON). The client and the server will be two separate projects. I build upon the <a href="http://web.archive.org/web/20220928131714/http://www.narwalengineering.com/2018/07/01/godot-tutorial-simple-chat-room-using-multiplayer-api/">NetworkedMultiplayerENet chat room tutorial by Miziziziz</a>(archive link) and the <a href="https://docs.godotengine.org/en/stable/tutorials/networking/websocket.html">HTML5 and WebSocket tutorial in the Godot documentation</a>. </p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Why WebSocket over UDP? </h2>
|
||||||
|
<p>UDP is fast but inaccurate. It is best used for real-time action gameplay. TCP is slow but accurate. It is best for sharing data. You can read more about it on the <a href="https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html">Multiplayer doc page</a>. </p>
|
||||||
|
<p>WebSocket uses a TCP connection. Ultimately, I am studying Godot to make a slow-paced adventure browser game, so this is one of the protocols I am considering and the protocol we will use for the tutorial. </p>
|
||||||
|
|
||||||
|
<h2>Do I need a dedicated server for testing? </h2>
|
||||||
|
<p>Nope! You can test client & server code on your own computer. I am only testing on a single computer at home for now without accessing an outside VPS, etc. </p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Make your projects. </h2>
|
||||||
|
<p>Make one for the server and one for the client. You can do almost everything in the engine. If you export your projects after changes, though, you can easily see debug information for both sides of the application and test multiple clients. Godot is fast and light for a game engine, so I don't think it's unreasonable to add an exporting step to your workflow. </p>
|
||||||
|
|
||||||
|
<p>I named my projects ChatServer and ChatClient. </p>
|
||||||
|
|
||||||
|
<h2>Setting up the server. </h2>
|
||||||
|
|
||||||
|
<h3>Node Hierarchy. </h3>
|
||||||
|
<p>One scene with a node is enough for the server. </p>
|
||||||
|
<img src="/static/img/ent/Chatbox_ServerHierarchy.png" alt="(Screenshot: Node hierarchy is just one node named Server.)"> <br>
|
||||||
|
|
||||||
|
<h3>Declare your variables. </h3>
|
||||||
|
<pre><code>
|
||||||
|
const PORT = 8080
|
||||||
|
var _server = WebSocketServer.new()
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p><code>PORT</code> is the port your applications will use. If you are having trouble connecting, try 9001, etc, until you find a free one. </p>
|
||||||
|
|
||||||
|
<h3>Build your skeleton. </h3>
|
||||||
|
<pre><code>
|
||||||
|
func _ready():
|
||||||
|
_server.connect('client_connected', self, "_connected")
|
||||||
|
_server.connect('client_disconnected', self, "_disconnected")
|
||||||
|
_server.connect('client_close_request', self, "_close_request")
|
||||||
|
_server.connect("data_received", self, "_on_data_received")
|
||||||
|
|
||||||
|
var err = _server.listen(PORT)
|
||||||
|
if err != OK:
|
||||||
|
print_debug("Unable to start server")
|
||||||
|
set_process(false)
|
||||||
|
print_debug("server started")
|
||||||
|
|
||||||
|
func _connected(id, protocol):
|
||||||
|
print_debug("Client %d connected with protocol:
|
||||||
|
%s" % [id, protocol])
|
||||||
|
|
||||||
|
func _close_request(id, code, reason):
|
||||||
|
print_debug("Client %d disconnecting with code:
|
||||||
|
%d, reason: %s" % [id, code, reason])
|
||||||
|
|
||||||
|
func _disconnected(id, was_clean = false):
|
||||||
|
print_debug("Client %d disconnected, clean:
|
||||||
|
%s" % [id, str(was_clean)])
|
||||||
|
|
||||||
|
func _on_data_received(id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _process(_delta):
|
||||||
|
_server.poll()
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>The <code>_ready</code> method prepares the server to catch all the signals it needs for a simple application. <code>client_connected</code> triggers when a client connects, <code>client_disconnected</code> when one disconnects, <code>client_close_request</code> when one requests to close, and <code>data_received</code> when a packet is received. Fairly obvious. The next block of code starts up the server, or at least makes an attempt. </p>
|
||||||
|
|
||||||
|
<p>So long as the process is running, the server polls constantly. </p>
|
||||||
|
|
||||||
|
<p>Let's start the client before we get any further. </p>
|
||||||
|
|
||||||
|
<h2>Setting up the client. </h2>
|
||||||
|
|
||||||
|
<h3>Design work. </h3>
|
||||||
|
<p>The client requires both some basic functionality and a barebones UI. </p>
|
||||||
|
<p>To go for this look... </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_UI.png" alt="(Screenshot: The look of the UI - a title bar on top, a large TextEdit field for everyone's messages, and a LineEdit beneath for entering messages. If you are 'logged out,' a username field and join button will appear at the bottom.)">
|
||||||
|
|
||||||
|
<p>...we'll need this tree. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_ClientHierarchy.png" alt="(Screenshot: Node hierarchy is a Node named Client a Panel Container child named UI. The UI has a VBoxContainer child containing a Label, TextEdit, LineEdit, and an HBoxContainer. The HBox contains a LineEdit and a button.)">
|
||||||
|
|
||||||
|
<p>The root node will hold the programmatic half of the client, while the UI node will hold all the code for displaying things to the user. </p>
|
||||||
|
|
||||||
|
<p>To make UIs, I like to block everything out in containers, especially VBoxContainers and HboxContainers. I made the UI a PanelContainer so everything lays on the default gray background (or any custom background, choosable in the Inspector as a StyleBoxTexture). </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_Stylebox.png" alt="(Screenshot: The PanelContainer Inspector>Theme Overrides>Styles>Panel>New StyleBoxTexture.)">
|
||||||
|
|
||||||
|
<p>Also in the Inspector, set the size (rect_size) to 250 by 600px. Make sure the Position is still (0,0). You can also match the window's size by going to the top-left menu>Project>Project Settings...>General>Display>Window>Size and set Width to 250 and Height to 600. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_UI_rect_size.png" alt="(Screenshot: PanelContainer Inspector>Rect>Size...x = 250 and y = 600.)">
|
||||||
|
<img src="/static/img/ent/Chatbox_ClientProjectSettings.png" alt="(Screenshot: Project Settings.)">
|
||||||
|
|
||||||
|
<p>The layout is more vertical overall, so the first structural node is a VBoxContainer. Add it as a child, then go to Inspector>Size Flags. Check Fill and Expand for both Horizontal and Vertical, so it automatically fills the PanelContainer. When you check Fill and Expand, it will take up 100% of the space in that direction of its parent when it is the only child. If there are multiple children, each with Expand, they will evenly divide the space. If one child has fixed dimensions, it will stay that size, while all the Expand children evenly split up the remainder. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_SizeFlags.png" alt="(Screenshot: VBoxContainer's Size Flags are checked for Horizontal Fill and Expand and Vertical Fill and Expand.)">
|
||||||
|
|
||||||
|
<p>For the Label, add the application's title in the Inspector's Text field, set Align to Center, and set the Size Flags to Horizontal Fill and Expand. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_LabelSizeFlags.png" alt="(Screenshot: VBoxContainer's Size Flags are checked for Horizontal Fill and Expand and Vertical Fill and Expand.)">
|
||||||
|
|
||||||
|
<p>For the ChatLog (TextEdit), check the Show Line Numbers and Wrap Enabled options then set Size Flags to Fill and Expand for both Horizontal and Vertical. </p>
|
||||||
|
|
||||||
|
<p>For the ChatInput (LineEdit), under Placeholder, set the Text to "Enter your message..." and the Size Flags to Fill and Expand Horizontally. </p>
|
||||||
|
|
||||||
|
<p>The next section is laid out horizontally, so use an HBoxContainer. Add a LineEdit for the Username with "Username" as its Placeholder Text and the Horizontal Fill and Expand boxes checked. Also add a button with its Text set to Join and its Horizontal Fill and Expand boxes checked.</p>
|
||||||
|
|
||||||
|
<h3>Client Skeleton </h3>
|
||||||
|
|
||||||
|
<p>Here is the first sketch of the UI code. The Client is accessible as <code>get_parent()</code>, the important nodes as variables, and the signals related to entering a message and pressing the join button are handled programmatically. </p>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
UI.gd (Client/UI PanelContainer)
|
||||||
|
|
||||||
|
extends PanelContainer
|
||||||
|
|
||||||
|
onready var chat_log = $VBox/ChatLog
|
||||||
|
onready var chat_input = $VBox/ChatInput
|
||||||
|
onready var join_button = $VBox/HBox/JoinButton
|
||||||
|
onready var username = $VBox/HBox/Name
|
||||||
|
|
||||||
|
func display_message(packet):
|
||||||
|
pass
|
||||||
|
|
||||||
|
func join_chat():
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _input(event):
|
||||||
|
if event is InputEventKey:
|
||||||
|
if event.pressed and event.scancode == KEY_ENTER:
|
||||||
|
get_parent().send_message(chat_input.text)
|
||||||
|
chat_input.text = ""
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
join_button.connect("button_up", self, "join_chat")
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>UI always takes up so much space without actually doing that much...Let's block out the functionality on the root node's script. </p>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Client.gd (Client Node)
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
export var websocket_url = "ws://127.0.0.1:8080"
|
||||||
|
var _client = WebSocketClient.new()
|
||||||
|
onready var UI = $UI
|
||||||
|
|
||||||
|
var chat_name
|
||||||
|
|
||||||
|
func join_chat(username):
|
||||||
|
pass
|
||||||
|
|
||||||
|
func send_message(message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sync func receive_message(packet):
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_client.connect("connection_closed", self, "_closed")
|
||||||
|
_client.connect("connection_error", self, "_error")
|
||||||
|
_client.connect("connection_established", self, "_connected")
|
||||||
|
_client.connect("data_received", self, "_on_data_received")
|
||||||
|
|
||||||
|
var err = _client.connect_to_url(websocket_url)
|
||||||
|
if err != OK:
|
||||||
|
print_debug("Unable to connect")
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
func _error(was_clean = false):
|
||||||
|
print_debug("Error, clean: ", was_clean)
|
||||||
|
|
||||||
|
func _closed(was_clean = false):
|
||||||
|
print_debug("Closed, clean: ", was_clean)
|
||||||
|
|
||||||
|
func _connected(protocol = ""):
|
||||||
|
print_debug("Connected with protocol: ", protocol)
|
||||||
|
|
||||||
|
func _on_data_received():
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _process(_delta):
|
||||||
|
_client.poll()
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>It resembles the server. You can add a security protocol when you use wss:// instead of ws:/ for the websocket_url, but that is outside the scope of this tutorial. 127.0.0.1 loops back to your own computer, so you can use this for testing at home without involving an outside VPS, etc. Then, the port must match whatever you use for the server's <code>PORT</code> constant. 8080 ended up working for me. </p>
|
||||||
|
|
||||||
|
<p>And with that, we have enough to try it out. </p>
|
||||||
|
|
||||||
|
<h3>Export & Run </h3>
|
||||||
|
|
||||||
|
<p>Export just the server for now. If you want to try multiple clients, export the client, too, but there isn't a lot to test yet. To Export, go to the top-left menu>Project>Export... If you've been here before, choose the right presets for making a standalone application for your computer. Otherwise, on the top, click Add... then choose your operating system. When you have your preset, make sure under the Resources tab, 'Export all resources in the project' is selected. Otherwise, all the default stuff is good. Personally, I keep all my Godot exports in Exports folder next to my Godot projects, but you put it somewhere accessible. If you have an option, export in Debug Mode. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_Export.png" alt="(Screenshot: Default export options for Linux.)">
|
||||||
|
|
||||||
|
<p>If you run in Linux, you can get the debug info in your terminal by opening a terminal in the export directory and typing <code>./ChatServer.x86_64</code> or whatever you named your application. </p>
|
||||||
|
|
||||||
|
<p>...Or you can just run both in their editors for now. No big deal. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_ClientConnected.png" alt="(Screenshot: Both applications are running and connecting to each other.)">
|
||||||
|
|
||||||
|
<p>But however you run, if you run the server first and then the client, you will see your connection in the debug output! You have completed an extremely basic networking application. You can see the full project at this stage on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver/src/commit/95dba37181f2cfa37fe03fe495b1c4b520c91101">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient/src/commit/a70269e9bcde4cdc66115f14c631b0073cdea91e">client</a>.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Communicating between Server + Client. </h2>
|
||||||
|
<p>If we can pass one more hurdle, writing the chat room code will barely even need a tutorial: passing information between the server and client. </p>
|
||||||
|
|
||||||
|
<h3>Writing packets in JSON. </h3>
|
||||||
|
<p>Information will be sent in packets. You can literally pass your chat message strings, etc, as packets without much touchup. Something like <code>put_packet("hey how's it going?".to_utf8())</code> will run, but even a little chat app will be passing lots of different data back and forth. JSON strings are freeform enough to fit any online application's needs. If you don't know JSON syntax, glance over the <a href="https://docs.godotengine.org/en/stable/classes/class_json.html">Godot documentation</a>. It's very close to the dictionary in most languages. </p>
|
||||||
|
|
||||||
|
<p>Some examples of packets we'll use are test packets, chat message packets, server message packets, user joining packets, and user leaving packets. I'm going to use the 'type' key to distinguish incoming packets. </p>
|
||||||
|
|
||||||
|
<p>My chat message packets look like this: </p>
|
||||||
|
|
||||||
|
<pre><code>{'type': 'chat_message', 'name': "Bell", 'message': "hey guys"}</code></pre>
|
||||||
|
|
||||||
|
<p>To send them, you need to convert them to JSON strings then convert those strings into UTF-8. You will have to do the reverse on the receiving end. Outgoing packets will look like </p>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': 'chat_message', 'name': "Bell", 'message': "hey guys"}).to_utf8()
|
||||||
|
)
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>while incoming messages will need extra preparation: </p>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
var json = JSON.parse(_client.get_peer(1).get_packet().get_string_from_utf8())
|
||||||
|
var packet = json.result
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<h3>JSON packets in action. </h3>
|
||||||
|
|
||||||
|
Let's build up the server's data reception method.
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Server.gd (Node)
|
||||||
|
func _on_data_received(id):
|
||||||
|
|
||||||
|
var original = _server.get_peer(id).get_packet().get_string_from_utf8()
|
||||||
|
var json = JSON.parse(original)
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
print_debug("Got data from client %d: %s" % [id, packet])
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>We don't have to send the <code>id</code> argument manually. The server will understand the source of incoming data automatically. <code>_server.get_peer(id)</code> gives us the source. <code>_server.get_peer(id).get_packet()</code> gives us the packet they are sending. </p>
|
||||||
|
|
||||||
|
<p><code>typeof</code> returns 18 for dictionaries. You can see all the types and their enum values in <a href="https://docs.godotengine.org/en/stable/classes/class_@globalscope.html">Godot's @GlobalScope documentation</a>. Packets should <i>always</i> be dictionaries the way I'm writing this, so if anything else is received, the application will push an error and crash. Replace the code with a print_debug warning if you want, but I like the immediacy of a crash. </p>
|
||||||
|
|
||||||
|
<p>For now, it will print any valid packet's contents.</p>
|
||||||
|
|
||||||
|
<p>The client's data reception method should be similar:</p>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Client.gd (Node)
|
||||||
|
func _on_data_received():
|
||||||
|
var json = JSON.parse(_client.get_peer(1).get_packet().get_string_from_utf8())
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
print_debug("Got data from server: ", packet)
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Let's add a few put_packets to receive, too. The _connected methods on the server and client are the easiest places to test. <p>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Server.gd (Node)
|
||||||
|
func _connected(id, protocol):
|
||||||
|
print_debug("Client %d connected with protocol: %s"
|
||||||
|
% [id, protocol])
|
||||||
|
_server.get_peer(id).put_packet(JSON.print(
|
||||||
|
{'type': 'test', 'message': "test packet from server"}).to_utf8())
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Client.gd (Node)
|
||||||
|
func _connected(protocol = ""):
|
||||||
|
print_debug("Connected with protocol: ", protocol)
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': 'test', 'message': "test packet from client"}).to_utf8())
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Let's see if the server and client can share data now. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_ClientCommunicating.png" alt="(Screenshot: Godot's debug info echos both test packets sent from the server and client.)">
|
||||||
|
|
||||||
|
<p>Boom. That's enough networking knowledge to get you started. You can see the full project at this stage on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver/src/commit/bea0dd872d6a7aa7e6fd941f16f20d5a89d5e676">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient/src/commit/6f78065920d5109b98c4823920cfa5a5f4d70247">client</a>.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Writing that chat room. </h2>
|
||||||
|
|
||||||
|
<h3>Writing the UI. </h3>
|
||||||
|
|
||||||
|
<p>A chat room is driven by the UI, so UI.gd is the best place to start. The UI needs to let the user join, send messages, and see messages sent by the user and others. Anything beyond showing buttons and letting the user type into fields is beyond the scope of the UI and will be passed to the root node. </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/Chatbox_Hide.png" alt="(Screenshot: The ChatInput is hidden at the start.)">
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
UI.gd (PanelContainer)
|
||||||
|
func display_message(packet):
|
||||||
|
if packet['type'] == "chat_message":
|
||||||
|
chat_log.text += packet['name'] + ": " + packet['message'] + "\n"
|
||||||
|
elif packet.has("message"):
|
||||||
|
chat_log.text += packet['message'] + "\n"
|
||||||
|
|
||||||
|
func join_chat():
|
||||||
|
if !username.text:
|
||||||
|
display_message(
|
||||||
|
{'type': "alert",
|
||||||
|
"message": "!!! Enter your username before joining chat."}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
get_parent().join_chat(username.text)
|
||||||
|
chat_input.show()
|
||||||
|
join_button.hide()
|
||||||
|
username.hide()
|
||||||
|
|
||||||
|
func _input(event):
|
||||||
|
if event is InputEventKey and chat_input.text:
|
||||||
|
if event.pressed and event.scancode == KEY_ENTER:
|
||||||
|
get_parent().send_message(chat_input.text)
|
||||||
|
chat_input.text = ""
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
join_button.connect("button_up", self, "join_chat")
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Also hide the ChatInput by clicking the eye on the node tree. </p>
|
||||||
|
|
||||||
|
<p>The <code>display_message</code> method will lightly format incoming chat messages and messages from other sources then show them as a line in the chat log. If the user clicks the join button but left the username field blank, an alert only visible to the user will be shown in the chat log. Otherwise, the join button and username field are hidden, and the chat input becomes available. <code>join_chat</code> is called off the parent, so the client can handle the rest. The <code>_input</code> method sends messages entered by the user to the root then clears the field for future messages. </p>
|
||||||
|
|
||||||
|
<h3>Writing the first part of the client's back end. </h3>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Client.gd (Node)
|
||||||
|
|
||||||
|
export var websocket_url = "ws://127.0.0.1:8080"
|
||||||
|
var _client = WebSocketClient.new()
|
||||||
|
onready var UI = $UI
|
||||||
|
var chat_name
|
||||||
|
|
||||||
|
func join_chat(username):
|
||||||
|
chat_name = username
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': "user_joined", 'name': username}
|
||||||
|
).to_utf8()
|
||||||
|
)
|
||||||
|
|
||||||
|
func send_message(message):
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': 'chat_message', 'name': chat_name, 'message': message}
|
||||||
|
).to_utf8()
|
||||||
|
)
|
||||||
|
|
||||||
|
func _error(was_clean = false):
|
||||||
|
print_debug("Error, clean: ", was_clean)
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
func _closed(was_clean = false):
|
||||||
|
print_debug("Closed, clean: ", was_clean)
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
func _connected(protocol = ""):
|
||||||
|
print_debug("Connected with protocol: ", protocol)
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': 'test', 'message': "test packet from client"}
|
||||||
|
).to_utf8())
|
||||||
|
UI.join_button.show()
|
||||||
|
UI.username.show()
|
||||||
|
|
||||||
|
func _on_data_received():
|
||||||
|
var json = JSON.parse(
|
||||||
|
_client.get_peer(1).get_packet().get_string_from_utf8()
|
||||||
|
)
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
print_debug("Got data from server: ", packet)
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>When the user clicks the join button, the client saves the username and sends it to the server. We can distinguish this from chat_messages, etc, by marking it as a "user_joined" packet. The client also sends the username and chat message from the UI's <code>_input</code> to the server. Both errors and voluntary or involuntary disconnections will stop the process. That covers everything we need to <i>send</i> messages. We'll cover <i>receiving</i> messages after we finish the server. </p>
|
||||||
|
|
||||||
|
<h3>Writing the server. </h3>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Server.gd (Node)
|
||||||
|
|
||||||
|
const PORT = 8080
|
||||||
|
var _server = WebSocketServer.new()
|
||||||
|
var IDs = {}
|
||||||
|
var peers = []
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_server.connect('client_connected', self, "_connected")
|
||||||
|
_server.connect('client_disconnected', self, "_disconnected")
|
||||||
|
_server.connect('client_close_request', self, "_close_request")
|
||||||
|
_server.connect("data_received", self, "_on_data_received")
|
||||||
|
|
||||||
|
var err = _server.listen(PORT)
|
||||||
|
if err != OK:
|
||||||
|
print_debug("Unable to start server")
|
||||||
|
set_process(false)
|
||||||
|
print_debug("server started")
|
||||||
|
|
||||||
|
func _connected(id, protocol):
|
||||||
|
print_debug("Client %d connected with protocol: %s" % [id, protocol])
|
||||||
|
_server.get_peer(id).put_packet(JSON.print(
|
||||||
|
{'type': 'test', 'message': "Server test packet"}).to_utf8()
|
||||||
|
)
|
||||||
|
peers.append(id)
|
||||||
|
|
||||||
|
func _close_request(id, code, reason):
|
||||||
|
print_debug("Client %d disconnecting with code: %d, reason: %s"
|
||||||
|
% [id, code, reason])
|
||||||
|
|
||||||
|
func _disconnected(id, was_clean = false):
|
||||||
|
print_debug("Client %d disconnected, clean: %s"
|
||||||
|
% [id, str(was_clean)])
|
||||||
|
var user = IDs[id]
|
||||||
|
peers.erase(id)
|
||||||
|
for p in peers:
|
||||||
|
_server.get_peer(p).put_packet(JSON.print(
|
||||||
|
{'type': "server_message", "message": user
|
||||||
|
+ "(#" + str(id) + ") left the chat."}
|
||||||
|
).to_utf8())
|
||||||
|
|
||||||
|
func _on_data_received(id):
|
||||||
|
|
||||||
|
var original = _server.get_peer(id).get_packet().get_string_from_utf8()
|
||||||
|
var json = JSON.parse(original)
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
print_debug("Got data from client %d: %s ... echoing" % [id, packet])
|
||||||
|
if packet['type'] == "user_joined":
|
||||||
|
IDs[id] = packet['name']
|
||||||
|
for p in peers:
|
||||||
|
_server.get_peer(p).put_packet(
|
||||||
|
JSON.print(
|
||||||
|
{'type': "server_message", "message": packet['name']
|
||||||
|
+ "(#" + str(id) + ") joined the chat."}
|
||||||
|
).to_utf8())
|
||||||
|
elif packet['type'] == "chat_message" or packet['type'] == "server_message":
|
||||||
|
for p in peers:
|
||||||
|
_server.get_peer(p).put_packet(JSON.print(packet).to_utf8())
|
||||||
|
|
||||||
|
func _process(_delta):
|
||||||
|
_server.poll()
|
||||||
|
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Upon a connection, the <code>_connected</code> method adds the id of the connection to the <code>peers</code> array. Upon a disconnection, it is erased. This way, you can access all users at any time by looping through the peers array. </p>
|
||||||
|
|
||||||
|
<p>The most dynamic method is <code>_on_data_received</code>, since it will react to a variety of packets. If it's a user_joined packet, it will save the id of his connection and his chosen username to the ID dictionary. Then the server will send a server_message to every connected client to announce the new member. If it is some kind of message, it will also send that to every client so everyone can see the same messages. </p>
|
||||||
|
|
||||||
|
<p>Lastly, upon a disconnection, the server will send a server_message to all clients about the departure. </p>
|
||||||
|
|
||||||
|
<p>Now let's make sure the client is ready to receive all these messages...and we'll be done! </p>
|
||||||
|
|
||||||
|
<h3>Finishing up the client. </h3>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
Client.gd (Node)
|
||||||
|
func receive_message(packet):
|
||||||
|
UI.display_message(packet)
|
||||||
|
|
||||||
|
func _on_data_received():
|
||||||
|
var json = JSON.parse(
|
||||||
|
_client.get_peer(1).get_packet().get_string_from_utf8()
|
||||||
|
)
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
print_debug("Got data from server: ", packet)
|
||||||
|
if packet['type'] == "chat_message" or packet['type'] == "server_message":
|
||||||
|
receive_message(packet)
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>If the client receives a chat_message or server_message from the server, it will call <code>receive_message</code>, which passes it to the UI for formatting and display. </p>
|
||||||
|
|
||||||
|
<p>Export both your server and client, so you can open lots of clients and fill your room with lots of people. Everyone will be able to see people joining, leaving, and chatting. Just like a real chat app! </p>
|
||||||
|
|
||||||
|
<img src="/static/img/ent/ChatClient_Conversation_1Kings22.png" alt="(Screenshot: A little chatplay using Jehoshaphat, Ahab, and Micaiah from 1 Kings 22.)">
|
||||||
|
|
||||||
|
<p>So the scrollbar covers some of the messages. And the scrolling is unusably annoying. The oldest messages should either disappear, or the chat should always be locked at the bottom on the latest messages. And there's no way to change your username without totally exiting and rejoining. And there must be some way to disable the pointless graphical window on the server. And there's probably a million more issues in addition to those. </p>
|
||||||
|
|
||||||
|
<p>But we're here to learn the basics, so we did an adequately good job. So good job anyway! Have fun improving from here and consider writing TCP guides of your own because UDP dominates the tutorial market. Later.^^ </p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Check over the finished scripts. </h2>
|
||||||
|
|
||||||
|
<p>You can see the finished project on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient">client</a>. </p>
|
||||||
|
|
||||||
|
<h3>Server side. </h3>
|
||||||
|
<pre><code>
|
||||||
|
Server.gd (Node)
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const PORT = 8080
|
||||||
|
var _server = WebSocketServer.new()
|
||||||
|
var IDs = {}
|
||||||
|
var peers = []
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_server.connect('client_connected', self, "_connected")
|
||||||
|
_server.connect('client_disconnected', self, "_disconnected")
|
||||||
|
_server.connect('client_close_request', self, "_close_request")
|
||||||
|
_server.connect("data_received", self, "_on_data_received")
|
||||||
|
|
||||||
|
var err = _server.listen(PORT)
|
||||||
|
if err != OK:
|
||||||
|
print_debug("Unable to start server")
|
||||||
|
set_process(false)
|
||||||
|
print_debug("server started")
|
||||||
|
|
||||||
|
func _connected(id, protocol):
|
||||||
|
print_debug("Client %d connected with protocol: %s" % [id, protocol])
|
||||||
|
_server.get_peer(id).put_packet(JSON.print(
|
||||||
|
{'type': 'test', 'message': "Server test packet"}).to_utf8()
|
||||||
|
)
|
||||||
|
peers.append(id)
|
||||||
|
|
||||||
|
func _close_request(id, code, reason):
|
||||||
|
print_debug("Client %d disconnecting with code: %d, reason: %s"
|
||||||
|
% [id, code, reason])
|
||||||
|
|
||||||
|
func _disconnected(id, was_clean = false):
|
||||||
|
print_debug("Client %d disconnected, clean: %s"
|
||||||
|
% [id, str(was_clean)])
|
||||||
|
var user = IDs[id]
|
||||||
|
peers.erase(id)
|
||||||
|
for p in peers:
|
||||||
|
_server.get_peer(p).put_packet(JSON.print(
|
||||||
|
{'type': "server_message", "message": user
|
||||||
|
+ "(#" + str(id) + ") left the chat."}
|
||||||
|
).to_utf8())
|
||||||
|
|
||||||
|
func _on_data_received(id):
|
||||||
|
|
||||||
|
var original = _server.get_peer(id).get_packet().get_string_from_utf8()
|
||||||
|
var json = JSON.parse(original)
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
print_debug("Got data from client %d: %s ... echoing" % [id, packet])
|
||||||
|
if packet['type'] == "user_joined":
|
||||||
|
IDs[id] = packet['name']
|
||||||
|
for p in peers:
|
||||||
|
_server.get_peer(p).put_packet(
|
||||||
|
JSON.print(
|
||||||
|
{'type': "server_message", "message": packet['name']
|
||||||
|
+ "(#" + str(id) + ") joined the chat."}
|
||||||
|
).to_utf8())
|
||||||
|
elif packet['type'] == "chat_message" or packet['type'] == "server_message":
|
||||||
|
for p in peers:
|
||||||
|
_server.get_peer(p).put_packet(JSON.print(packet).to_utf8())
|
||||||
|
|
||||||
|
func _process(_delta):
|
||||||
|
_server.poll()
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<h3>Client side. </h3>
|
||||||
|
<pre><code>
|
||||||
|
Client.gd (Node)
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
export var websocket_url = "ws://127.0.0.1:8080"
|
||||||
|
var _client = WebSocketClient.new()
|
||||||
|
onready var UI = $UI
|
||||||
|
var chat_name
|
||||||
|
|
||||||
|
func join_chat(username):
|
||||||
|
chat_name = username
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': "user_joined", 'name': username}
|
||||||
|
).to_utf8()
|
||||||
|
)
|
||||||
|
|
||||||
|
func send_message(message):
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': 'chat_message', 'name': chat_name, 'message': message}
|
||||||
|
).to_utf8()
|
||||||
|
)
|
||||||
|
|
||||||
|
func receive_message(packet):
|
||||||
|
UI.display_message(packet)
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_client.connect("connection_closed", self, "_closed")
|
||||||
|
_client.connect("connection_error", self, "_error")
|
||||||
|
_client.connect("connection_established", self, "_connected")
|
||||||
|
_client.connect("data_received", self, "_on_data_received")
|
||||||
|
|
||||||
|
var err = _client.connect_to_url(websocket_url)
|
||||||
|
if err != OK:
|
||||||
|
print_debug("Unable to connect")
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
func _error(was_clean = false):
|
||||||
|
print_debug("Error. Clean break? ", was_clean)
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
func _closed(was_clean = false):
|
||||||
|
print_debug("Closed. Clean break? ", was_clean)
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
func _connected(protocol = ""):
|
||||||
|
print_debug("Connected with protocol: ", protocol)
|
||||||
|
_client.get_peer(1).put_packet(JSON.print(
|
||||||
|
{'type': 'test', 'message': "test packet from client"}
|
||||||
|
).to_utf8())
|
||||||
|
UI.join_button.show()
|
||||||
|
UI.username.show()
|
||||||
|
|
||||||
|
func _on_data_received():
|
||||||
|
var json = JSON.parse(
|
||||||
|
_client.get_peer(1).get_packet().get_string_from_utf8()
|
||||||
|
)
|
||||||
|
var packet = json.result
|
||||||
|
if typeof(packet) != 18:
|
||||||
|
push_error("%s is not a dictionary" % [packet])
|
||||||
|
get_tree().quit()
|
||||||
|
print_debug("Got data from server: ", packet)
|
||||||
|
if packet['type'] == "chat_message" or packet['type'] == "server_message":
|
||||||
|
receive_message(packet)
|
||||||
|
|
||||||
|
func _process(_delta):
|
||||||
|
_client.poll()
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<pre><code>
|
||||||
|
UI.gd (PanelContainer)
|
||||||
|
|
||||||
|
extends PanelContainer
|
||||||
|
|
||||||
|
onready var chat_log = $VBox/ChatLog
|
||||||
|
onready var chat_input = $VBox/ChatInput
|
||||||
|
onready var join_button = $VBox/HBox/JoinButton
|
||||||
|
onready var username = $VBox/HBox/Name
|
||||||
|
|
||||||
|
func display_message(packet):
|
||||||
|
if packet['type'] == "chat_message":
|
||||||
|
chat_log.text += packet['name'] + ": " + packet['message'] + "\n"
|
||||||
|
elif packet.has("message"):
|
||||||
|
chat_log.text += packet['message'] + "\n"
|
||||||
|
|
||||||
|
func join_chat():
|
||||||
|
if !username.text:
|
||||||
|
display_message(
|
||||||
|
{'type': "alert", "message": "!!! Enter your username before joining chat."}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
get_parent().join_chat(username.text)
|
||||||
|
chat_input.show()
|
||||||
|
join_button.hide()
|
||||||
|
username.hide()
|
||||||
|
|
||||||
|
func _input(event):
|
||||||
|
if event is InputEventKey and chat_input.text:
|
||||||
|
if event.pressed and event.scancode == KEY_ENTER:
|
||||||
|
get_parent().send_message(chat_input.text)
|
||||||
|
chat_input.text = ""
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
join_button.connect("button_up", self, "join_chat")
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<p>Last updated February 26, 2022 </p>
|
||||||
|
<br>
|
After Width: | Height: | Size: 199 KiB |
After Width: | Height: | Size: 189 KiB |
After Width: | Height: | Size: 57 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 45 KiB |
After Width: | Height: | Size: 1.9 MiB |
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 1.8 MiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 784 KiB |
After Width: | Height: | Size: 529 KiB |
@ -0,0 +1,60 @@
|
|||||||
|
goal: establish that Aries cares about Helia
|
||||||
|
|
||||||
|
<Helia! You came. Cool.
|
||||||
|
>Yeah. I don't think it would take much to get me up to speed.
|
||||||
|
<Nah, you got this.
|
||||||
|
<Let's see...
|
||||||
|
<You've got the basics.
|
||||||
|
<Your DPS is pretty low.
|
||||||
|
<Your weapon and gear doesn't really match your job.
|
||||||
|
<Here, you can borrow this sword. Give it back whenever, don't feel pressure about it.
|
||||||
|
<Now then. You should prioritize this skill, use skills more often, whatever.
|
||||||
|
<Would you like to spar? Or you need some time on the practice dummy?
|
||||||
|
>Let's spar!
|
||||||
|
<Sure, let's go.
|
||||||
|
|
||||||
|
<Hey, you're improving.
|
||||||
|
>Think so?
|
||||||
|
<Yeah, I won't flatter you.
|
||||||
|
|
||||||
|
>With practice, you'll learn.
|
||||||
|
>Eh, that wasn't your best.
|
||||||
|
>You'll get it. Keep trying.
|
||||||
|
<I'm giving up for now...
|
||||||
|
>Okay.
|
||||||
|
|
||||||
|
<So- how do you like sparring?
|
||||||
|
>I come here looking for you.
|
||||||
|
<...(raised eyebrows)
|
||||||
|
<Yeah? If you don't like gym stuff, you won't impress me by forcing it, you know?
|
||||||
|
<I like when a girl has her own interests.
|
||||||
|
>Oh, this is awkward. No, I don't really like the gym.
|
||||||
|
<Don't worry about it. It's forgotten.
|
||||||
|
<(if dating) Do I not take you out enough?
|
||||||
|
<(else) Hmm. Want to meet at the park tomorrow?
|
||||||
|
>Yeah!
|
||||||
|
<Cool. Text ya then. Later.
|
||||||
|
|
||||||
|
>It's exciting.
|
||||||
|
<Heh. I didn't think yankees were into that kind of stuff. You're pretty cool.
|
||||||
|
>I've never done it before.
|
||||||
|
<Well, it's not just exercise, you know. If you're ever in danger, it's a lot better if you can defend yourself instead of hoping someone else will save you.
|
||||||
|
>Is that important to you? Surely that is never necessary here. It seems so safe and slow.
|
||||||
|
<It's just a self-sufficiency thing.
|
||||||
|
<I know you're a far way away from your family and don't really know anyone.
|
||||||
|
<I'd just feel like you're safer if I know you can defend yourself.
|
||||||
|
>I'm fine!
|
||||||
|
<You could stand to learn more.
|
||||||
|
>Oh...I appreciate the training.
|
||||||
|
<Yeah, they can only teach so much at school. A lot of this comes from doing it.
|
||||||
|
|
||||||
|
>No...I'd rather not, honestly.
|
||||||
|
<But you train anyway! That's pretty admirable.
|
||||||
|
<A lot of people say they love it, but I never see them practicing.
|
||||||
|
>You know, I never feel like working out before I get here.
|
||||||
|
<You? Really?
|
||||||
|
>Oh yes. I have to force it. It feels good once I've started, but breaking the inertia.
|
||||||
|
<That's funny. Is everyone like that, then?
|
||||||
|
>Probably not. Probably just us. (smiles slyly)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
|||||||
|
<Oh-? What are you looking for here? (at the library, chance meeting)
|
||||||
|
>It's a quiet place to be.
|
||||||
|
<I know what you mean. But I won't bother you.
|
||||||
|
>No- let's chat.
|
||||||
|
(go to tables)
|
||||||
|
<I'm not doing anything particularly interesting, either.
|
@ -0,0 +1,38 @@
|
|||||||
|
% import random
|
||||||
|
% rebase('frame.tpl')
|
||||||
|
<div class="content-grid">
|
||||||
|
<h1>lemonland milestones </h1>
|
||||||
|
<p>This is a list of everything I need to add before the Blessfrey is complete, version by version. I can add more in subsequent updates, but I have to draw the line somewhere. The list is broken into versions. </p>
|
||||||
|
<br>
|
||||||
|
<p>Focus and finish the game! <br></p>
|
||||||
|
|
||||||
|
<br><hr><br>
|
||||||
|
<h2>legend </h2>
|
||||||
|
<p><span class=mundane>nothing at all</span>, <span class=common>designed</span>, <span class=unusual>basic implementation</span>, <span class=rare>intentionally designed, documented, but with known issues</span>, <span class=unique>playtested and polished...or at least as done as it will ever be</span> </p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<br><hr><br>
|
||||||
|
|
||||||
|
|
||||||
|
<h2><span class=unique>0.0 - ship literally anything at all</span> </h2>
|
||||||
|
<ul>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=unique>feature: export, embed</span> </li>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=mundane>usernames must be unique</span> </li>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=mundane>pet names must be unique</span> </li>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=mundane>usernames can't have unexpected characters like あ</span> </li>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=mundane>pet names can't have unexpected characters like あ</span> </li>
|
||||||
|
</ul>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=mundane>accounts have a persistent username </span> </li>
|
||||||
|
<li class="latest">{{random.choice(['.','•','☆','★'])}}	<span class=mundane>can capture pet and add it to your pet list </span> </li>
|
||||||
|
|
||||||
|
|
||||||
|
<br><hr><br>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
-->
|
||||||
|
|