summaryrefslogtreecommitdiff
path: root/asciifarmclient
diff options
context:
space:
mode:
Diffstat (limited to 'asciifarmclient')
-rw-r--r--asciifarmclient/__init__.py0
-rw-r--r--asciifarmclient/__main__.py16
-rw-r--r--asciifarmclient/commandhandler.py150
-rw-r--r--asciifarmclient/common/__init__.py0
-rw-r--r--asciifarmclient/common/messages.py157
-rw-r--r--asciifarmclient/common/tcommunicate.py32
-rw-r--r--asciifarmclient/common/utils.py47
-rw-r--r--asciifarmclient/connection.py30
-rw-r--r--asciifarmclient/display.py174
-rw-r--r--asciifarmclient/gameclient.py173
-rw-r--r--asciifarmclient/inputhandler.py98
-rw-r--r--asciifarmclient/layout.xml44
-rw-r--r--asciifarmclient/listselector.py49
-rw-r--r--asciifarmclient/loaders.py81
-rw-r--r--asciifarmclient/main.py65
-rw-r--r--asciifarmclient/parseargs.py66
-rw-r--r--asciifarmclient/paths.py7
17 files changed, 1189 insertions, 0 deletions
diff --git a/asciifarmclient/__init__.py b/asciifarmclient/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/asciifarmclient/__init__.py
diff --git a/asciifarmclient/__main__.py b/asciifarmclient/__main__.py
new file mode 100644
index 0000000..d086dfb
--- /dev/null
+++ b/asciifarmclient/__main__.py
@@ -0,0 +1,16 @@
+
+
+import sys
+
+if sys.version_info[0] < 3:
+ print("This game is written in python 3.\nRun 'python3 "+sys.argv[0]+"' or './"+sys.argv[0]+"'")
+ sys.exit(-1)
+
+if __package__ == "asciifarmclient":
+ from . import main
+else:
+ import os.path
+ sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
+ from asciifarmclient import main
+
+main.main()
diff --git a/asciifarmclient/commandhandler.py b/asciifarmclient/commandhandler.py
new file mode 100644
index 0000000..ec5baa3
--- /dev/null
+++ b/asciifarmclient/commandhandler.py
@@ -0,0 +1,150 @@
+
+import json
+
+try:
+ import hy
+except ImportError as e:
+ hy = None
+ hyErr = e
+
+class InvalidCommandException(Exception):
+ pass
+
+
+class CommandHandler:
+
+ def __init__(self, client):
+ self.client = client
+
+ self.commands = {
+ "input": self.input,
+ "move": self.move,
+ "say": self.say,
+ "pick": self.pick,
+ "chat": self.chat,
+ "log": self.log,
+ "do": self.do,
+ "runinput": self.runInput,
+ "selectwidget": self.selectWidget,
+ "selectitem": self.selectItem,
+ "inputwithselected": self.actWithSelected,
+ "use": self.useSelected,
+ "drop": self.dropSelected,
+ "take": self.takeSelected,
+ "eval": self.eval,
+ "exec": self.exec,
+ "scrollchat": self.scrollChat,
+ "json": self.json,
+ "j": self.json,
+ "ijson": self.ijson,
+ "ij": self.ijson,
+ "hy": self.hy
+ }
+
+ self.evalArgs = {
+ "self": self,
+ "client": self.client,
+ "connection": self.client.connection,
+ "display": self.client.display,
+ "print": self.log
+ }
+
+ def execute(self, action):
+ if action is None:
+ return
+ if isinstance(action[0], str):
+ command = action[0]
+ if command in self.commands:
+ self.commands[command](*action[1:])
+ else:
+ raise InvalidCommandException("Invalid command '{}'".format(command))
+ else:
+ raise Exception("Command should be a string")
+
+
+ # Commands
+
+ def input(self, action):
+ self.client.sendInput(action)
+
+ def move(self, direction):
+ self.input(["move", direction])
+
+ def say(self, text):
+ self.input(["say", text])
+
+ def pick(self, option):
+ self.input(["interact", [None, "north", "south", "east", "west"], option])
+
+ def chat(self, text):
+ self.client.sendChat( text)
+
+
+ def log(self, text):
+ self.client.log(text)
+
+ def do(self, actions):
+ for action in actions:
+ self.execute(action)
+
+ def runInput(self, startText=""):
+ self.client.inputHandler.startTyping(startText)
+
+ def selectWidget(self, value, relative=False, modular=False):
+ self.client.display.selectMenu(value, relative, modular)
+
+ def selectItem(self, value, relative=False, modular=False):
+ self.client.display.selectItem(None, value, relative, modular)
+
+ def actWithSelected(self, action, menu):
+ self.input([action, self.client.display.getSelectedItem(menu).getSelected()])
+
+ def useSelected(self):
+ menu = self.client.display.getSelectedMenu()
+ selected = self.client.display.getSelectedItem(menu)
+ if menu in ("inventory", "equipment"):
+ self.input(["use", menu, selected])
+ elif menu == "ground":
+ self.input(["interact", selected])
+
+ def dropSelected(self):
+ menu = self.client.display.getSelectedMenu()
+ selected = self.client.display.getSelectedItem(menu)
+ if menu == "inventory":
+ action = "drop"
+ else:
+ return
+ self.input([action, selected])
+
+ def takeSelected(self):
+ menu = self.client.display.getSelectedMenu()
+ selected = self.client.display.getSelectedItem(menu)
+ if menu == "ground":
+ action = "take"
+ else:
+ return
+ self.input([action, selected])
+
+ def eval(self, text):
+ self.log(eval(text, self.evalArgs))
+
+ def exec(self, text):
+ exec(text, self.evalArgs)
+
+ def hy(self, code):
+ if hy is None:
+ self.log(hyErr)
+ return
+ expr = hy.read_str(code)
+ self.log(hy.eval(expr, self.evalArgs))
+
+ def scrollChat(self, lines):
+ self.client.display.scrollBack(lines)
+
+ def json(self, text):
+ self.execute(json.loads(text))
+
+ def ijson(self, text):
+ self.input(json.loads(text))
+
+
diff --git a/asciifarmclient/common/__init__.py b/asciifarmclient/common/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/asciifarmclient/common/__init__.py
diff --git a/asciifarmclient/common/messages.py b/asciifarmclient/common/messages.py
new file mode 100644
index 0000000..d3aed9a
--- /dev/null
+++ b/asciifarmclient/common/messages.py
@@ -0,0 +1,157 @@
+
+import re
+import unicodedata
+import json
+
+class InvalidMessageError(Exception):
+ errType = "invalidmessage"
+ description = ""
+
+ def __init__(self, description="", errType=None):
+ self.description = description
+ if errType is not None:
+ self.errType = errType
+
+ def toMessage(self):
+ return ErrorMessage(self.errType, self.description)
+
+class InvalidNameError(InvalidMessageError):
+ errType = "invalidname"
+
+class Message:
+
+ @classmethod
+ def msgType(cls):
+ return cls.typename
+
+ def to_json(self):
+ raise NotImplementedError
+
+ def to_json_bytes(self):
+ return bytes(json.dumps(self.to_json()), "utf-8")
+
+ @classmethod
+ def from_json(cls, jsonobj):
+ raise NotImplementedError
+
+class ClientToServerMessage(Message):
+
+ def body(self):
+ raise NotImplementedError
+
+ def to_json(self):
+ return [self.typename, self.body()]
+
+ @classmethod
+ def from_json(cls, jsonlist):
+ assert len(jsonlist) == 2, InvalidMessageError
+ typename, body = jsonlist
+ assert typename == cls.msgType(), InvalidMessageError
+ return cls(body)
+
+
+class NameMessage(ClientToServerMessage):
+
+ typename = "name"
+ categories = {"Lu", "Ll", "Lt", "Lm", "Lo", "Nd", "Nl", "No", "Pc"}
+
+
+ def __init__(self, name):
+ assert isinstance(name, str), InvalidNameError("name must be a string")
+ assert (len(name) > 0), InvalidNameError("name needs at least one character")
+ assert (len(bytes(name, "utf-8")) <= 256), InvalidNameError("name may not be longer than 256 utf8 bytes")
+ if name[0] != "~":
+ for char in name:
+ category = unicodedata.category(char)
+ assert category in self.categories, InvalidNameError("all name caracters must be in these unicode categories: " + "|".join(self.categories) + " (except for tildenames)")
+ self.name = name
+
+ def body(self):
+ return self.name
+
+
+class InputMessage(ClientToServerMessage):
+
+ typename = "input"
+
+ def __init__(self, inp):
+ self.inp = inp
+
+ def body(self):
+ return self.inp
+
+class ChatMessage(ClientToServerMessage):
+
+ typename = "chat"
+
+ def __init__(self, text):
+ assert isinstance(text, str), InvalidMessageError("chat message must be a string")
+ assert text.isprintable(), InvalidMessageError("chat messages may only contain printable unicode characters")
+ self.text = text
+
+ def body(self):
+ return self.text
+
+
+
+class ServerToClientMessage(Message):
+ msglen = 0
+
+
+ @classmethod
+ def from_json(cls, jsonlist):
+ assert len(jsonlist) == cls.msglen, InvalidMessageError
+ assert jsonlist[0] == cls.msgType(), InvalidMessageError
+ return cls(*jsonlist[1:])
+
+
+class MessageMessage(ServerToClientMessage): # this name feels stupid
+ """ A message to inform the client. This is meant to be read by the user"""
+
+ typename = "message"
+ msglen = 3
+
+ def __init__(self, text, type=""):
+ self.text = text
+ self.type = type
+
+ def to_json(self):
+ return [self.typename, self.text, self.type]
+
+
+class WorldMessage(ServerToClientMessage):
+ """ A message about the world state """
+
+ typename = "world"
+ msglen = 2
+
+ def __init__(self, updates):
+ assert isinstance(updates, list), InvalidMessageError
+ self.updates = updates
+
+ def to_json(self):
+ return [self.typename, self.updates]
+
+class ErrorMessage(ServerToClientMessage):
+
+ typename = "error"
+ msglen = 3
+
+ def __init__(self, errType, description=""):
+ self.errType = errType
+ self.description = description
+
+ def to_json(self):
+ return [self.typename, self.errType, self.description]
+
+
+
+messages = {message.msgType(): message for message in [
+ NameMessage,
+ InputMessage,
+ ChatMessage,
+ WorldMessage,
+ ErrorMessage,
+ MessageMessage
+]}
+
diff --git a/asciifarmclient/common/tcommunicate.py b/asciifarmclient/common/tcommunicate.py
new file mode 100644
index 0000000..b1fc1b0
--- /dev/null
+++ b/asciifarmclient/common/tcommunicate.py
@@ -0,0 +1,32 @@
+
+HEADER_SIZE = 4
+
+
+# this module is for sending discree messages over TCP
+# this is achieved by prefixing all messages with their length
+# calls to send and recv will also keep attempting to send all data unless this proves impossible
+
+
+def send(sock, msg):
+ length = len(msg)
+ header = length.to_bytes(4, byteorder="big")
+ totalmsg = header + msg
+ sock.sendall(totalmsg)
+
+def receive(sock):
+ header = recvall(sock, 4) #sock.recv(4)
+ length = int.from_bytes(header, byteorder="big")
+ return recvall(sock, length)
+
+def recvall(sock, length):
+ chunks = []
+ bytes_recd = 0
+ while bytes_recd < length:
+ chunk = sock.recv(min(length - bytes_recd, 4096))
+ if chunk == b'':
+ break
+ #raise RuntimeError("socket connection broken")
+ chunks.append(chunk)
+ bytes_recd = bytes_recd + len(chunk)
+ return b''.join(chunks)
+
diff --git a/asciifarmclient/common/utils.py b/asciifarmclient/common/utils.py
new file mode 100644
index 0000000..95ac32b
--- /dev/null
+++ b/asciifarmclient/common/utils.py
@@ -0,0 +1,47 @@
+
+import os
+
+def clamp(val, lower, upper):
+ """ val if it's between lower and upper, else the closest of the two"""
+ return max(min(val, upper), lower)
+
+
+def concat(arr):
+ """Takes a list of sequences, returns the concatenation of the sequences """
+ if isinstance(arr[0], str):
+ return "".join(arr)
+ if isinstance(arr[0], bytes):
+ return b"".join(arr)
+ if isinstance(arr[0], list):
+ l = []
+ for s in arr:
+ l += s
+ return l
+ if isinstance(arr[0], tuple):
+ l = []
+ for s in arr:
+ l += s
+ return tuple(l)
+ else:
+ raise ValueError("type {} can't be concatenated".format(type(arr[0])))
+
+
+def writeFileSafe(filename, data, tempname=None):
+ if tempname is None:
+ tempname = filename + ".tempfile"
+ with open(tempname, 'w') as f:
+ f.write(data)
+ os.rename(tempname, filename)
+
+
+def readFile(filepath):
+ with open(filepath, "r") as f:
+ text = f.read()
+ return text
+
+
+def get(collection, i, default=None):
+ """ Get an element in an indexed collection, or the default in case the index is out of bounds """
+ if i < 0 or i >= len(collection):
+ return default
+ return collection[i]
diff --git a/asciifarmclient/connection.py b/asciifarmclient/connection.py
new file mode 100644
index 0000000..370ef8a
--- /dev/null
+++ b/asciifarmclient/connection.py
@@ -0,0 +1,30 @@
+
+import socket
+
+from asciifarmclient.common.tcommunicate import send, receive
+
+class Connection:
+
+ def __init__(self, socketType):
+ if socketType == "abstract" or socketType == "unix":
+ sockType = socket.AF_UNIX
+ elif socketType == "inet":
+ sockType = socket.AF_INET
+ else:
+ raise ValueError("Invalid socket type: %r" % (socketType,))
+ self.sock = socket.socket(sockType, socket.SOCK_STREAM)
+
+ def connect(self, address):
+ self.sock.connect(address)
+
+ def listen(self, callback, onError):
+ while True:
+ try:
+ data = receive(self.sock)
+ except Exception as err:
+ onError(err)
+ else:
+ callback(data)
+
+ def send(self, message):
+ send(self.sock, message)
diff --git a/asciifarmclient/display.py b/asciifarmclient/display.py
new file mode 100644
index 0000000..ae23b05
--- /dev/null
+++ b/asciifarmclient/display.py
@@ -0,0 +1,174 @@
+
+
+
+import os
+from ratuil.layout import Layout
+from ratuil.bufferedscreen import BufferedScreen as Screen
+#from ratuil.screen import Screen
+from ratuil.textstyle import TextStyle
+from asciifarmclient.common.utils import get
+from .listselector import ListSelector
+
+
+SIDEWIDTH = 20
+
+ALPHABET = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
+
+class Display:
+
+ def __init__(self, charMap):
+
+ self.characters = {}
+
+ def parseSprite(sprite):
+ if isinstance(sprite, str):
+ return (sprite, None, None)
+ char = get(sprite, 0, " ")
+ fg = get(sprite, 1)
+ bg = get(sprite, 2)
+ return (char, fg, bg)
+ for name, sprite in charMap["mapping"].items():
+ vals = parseSprite(sprite)
+ if vals:
+ self.characters[name] = vals
+
+ for name, colours in charMap.get("writable", {}).items():
+ fg = get(colours, 0)
+ bg = get(colours, 1)
+ for i in range(min(len(ALPHABET), len(charMap.get("alphabet", [])))):
+ self.characters[name + '-' + ALPHABET[i]] = (charMap["alphabet"][i], fg, bg)
+
+ self.defaultChar = parseSprite(charMap.get("default", "?"))
+
+ self.messageColours = charMap.get("msgcolours", {})
+
+ fname = os.path.join(os.path.dirname(__file__), "layout.xml")
+ self.layout = Layout.from_xml_file(fname)
+ self.layout.get("field").set_char_size(charMap.get("charwidth", 1))
+
+ self.screen = Screen()
+ self.screen.clear()
+
+ self.layout.set_target(self.screen)
+ self.layout.update()
+
+
+
+ # temporary, until these have a better place
+ self.inventory = ListSelector(self.getWidget("inventory"))
+ self.inventory._debug_name = "inventory"
+ self.equipment = ListSelector(self.getWidget("equipment"))
+ self.equipment._debug_name = "equipment"
+ self.ground = ListSelector(self.getWidget("ground"))
+ self.ground._debug_name = "ground"
+ self.switch = ListSelector(self.getWidget("switchtitles"))
+ self.switch._debug_name = "switch"
+
+ self.switch.setItems(["inventory", "equipment", "ground"])
+ self.menus = {
+ "inventory": self.inventory,
+ "equipment": self.equipment,
+ "ground": self.ground
+ }
+
+ self.layout.get("switch").select(0)
+
+
+ def getWidget(self, name):
+ return self.layout.get(name)
+
+ def resizeField(self, size):
+ self.getWidget("field").set_size(*size)
+ self.getWidget("fieldbackground").change()
+
+ def drawFieldCells(self, cells):
+ field = self.getWidget("field")
+ for cell in cells:
+ (x, y), spriteNames = cell
+ if not len(spriteNames):
+ char, fg, bg = self.getChar(' ')
+ else:
+ char, fg, bg = self.getChar(spriteNames[0])
+ for spriteName in spriteNames[1:]:
+ if bg is not None:
+ break
+ _char, _fg, bg = self.getChar(spriteName)
+ field.change_cell(x, y, char, TextStyle(fg, bg))
+
+
+ def setFieldCenter(self, pos):
+ self.getWidget("field").set_center(*pos)
+
+ def setHealth(self, health, maxHealth):
+ if health is None:
+ health = 0
+ if maxHealth is None:
+ maxHealth = 0
+ self.getWidget("health").set_total(maxHealth)
+ self.getWidget("health").set_filled(health)
+ self.getWidget("healthtitle").format({"filled": health, "total":maxHealth})
+
+
+ def showInfo(self, infostring):
+ self.getWidget("info").set_text(infostring)
+
+ def selectMenu(self, *args, **kwargs):
+ self.switch.select(*args, **kwargs)
+ self.layout.get("switch").select(self.getSelectedMenu())
+
+ def getSelectedMenu(self):
+ return self.switch.getSelectedItem()
+
+ def getSelectedItem(self, menu=None):
+ return self._getMenu(menu).getSelected()
+
+ def selectItem(self, menu=None, *args, **kwargs):
+ self._getMenu(menu).select(*args, **kwargs)
+
+ def _getMenu(self, name=None):
+ if name is None:
+ name = self.getSelectedMenu()
+ name = name.casefold()
+ return self.menus[name]
+
+ def setInventory(self, items):
+ self.inventory.setItems(items)
+
+
+ def setEquipment(self, slots):
+ self.equipment.setItems([
+ slot + ": " + (item if item else "")
+ for slot, item in slots
+ ])
+
+ def setGround(self, items):
+ self.ground.setItems(items)
+
+
+ def addMessage(self, message, msgtype=None):
+ if msgtype is not None:
+ style = TextStyle(*self.messageColours.get(msgtype, (7,0)))
+ else:
+ style = None
+ self.getWidget("msg").add_message(message, style)
+
+ def log(self, message):
+ self.addMessage(str(message))
+
+ def scrollBack(self, amount, relative=True):
+ self.getWidget("msg").scroll(amount, relative)
+
+ def setInputString(self, string, cursor):
+ self.getWidget("textinput").set_text(string, cursor)
+
+ def update(self):
+ self.layout.update()
+ self.screen.update()
+
+ def getChar(self, sprite):
+ """This returns the character belonging to some spritename. This does not read a character"""
+ return self.characters.get(sprite, self.defaultChar)
+
+ def update_size(self):
+ self.screen.reset()
+
diff --git a/asciifarmclient/gameclient.py b/asciifarmclient/gameclient.py
new file mode 100644
index 0000000..d677636
--- /dev/null
+++ b/asciifarmclient/gameclient.py
@@ -0,0 +1,173 @@
+
+
+import os
+import sys
+
+import threading
+import json
+import getpass
+import argparse
+import string
+from queue import Queue
+
+import ratuil.inputs
+
+from .inputhandler import InputHandler
+from asciifarmclient.common import messages
+
+class Client:
+
+ def __init__(self, display, name, connection, keybindings, logFile=None):
+
+ self.display = display
+ self.name = name
+ self.keepalive = True
+ self.connection = connection
+ self.logFile = logFile
+ self.closeMessage = None
+
+ self.inputHandler = InputHandler(self, keybindings["actions"])
+
+ self.controlsString = keybindings.get("help", "")
+
+ self.display.showInfo(self.controlsString)
+ self.queue = Queue()
+
+
+ def sendMessage(self, message):
+ self.connection.send(message.to_json_bytes())
+
+ def sendInput(self, inp):
+ message = messages.InputMessage(inp)
+ self.sendMessage(message)
+
+ def sendChat(self, text):
+ try:
+ self.sendMessage(messages.ChatMessage(text))
+ except messages.InvalidMessageError as e:
+ self.log(e.description)
+
+ def start(self):
+ self.sendMessage(messages.NameMessage(self.name))
+ threading.Thread(target=self.listen, daemon=True).start()
+ threading.Thread(target=self.getInput, daemon=True).start()
+
+ self.command_loop()
+
+ def listen(self):
+ self.connection.listen(self.pushMessage, self.onConnectionError)
+
+ def pushMessage(self, databytes):
+ self.queue.put(("message", databytes))
+
+ def onConnectionError(self, error):
+ self.queue.put(("error", error))
+
+ def getInput(self):
+ while True:
+ key = ratuil.inputs.get_key()
+ self.queue.put(("input", key))
+
+ def close(self, msg=None):
+ self.keepalive = False
+ self.closeMessage = msg
+
+
+ def update(self, databytes):
+ if len(databytes) == 0:
+ self.close("Connection closed by server")
+ return
+ datastr = databytes.decode('utf-8')
+ msg = json.loads(datastr)
+ message = messages.messages[msg[0]].from_json(msg)
+ if isinstance(message, messages.ErrorMessage):
+ error = message.errType
+ if error == "nametaken":
+ self.close("error: name is already taken")
+ return
+ if error == "invalidname":
+ self.close("Invalid name error: "+ str(message.description))
+ return
+ self.log(message.errType + ": " + message.description)
+ elif isinstance(message, messages.MessageMessage):
+ self.log(message.text, message.type)
+ elif isinstance(message, messages.WorldMessage):
+ for msg in message.updates:
+ self.handleWorldUpdate(msg)
+
+ def handleWorldUpdate(self, msg):
+ msgType = msg[0]
+ if msgType == 'field':
+ field = msg[1]
+ fieldWidth = field['width']
+ fieldHeight = field['height']
+ self.display.resizeField((fieldWidth, fieldHeight))
+ fieldCells = field['field']
+ mapping = field['mapping']
+ self.display.drawFieldCells(
+ (
+ tuple(reversed(divmod(i, fieldWidth))),
+ mapping[spr]
+ )
+ for i, spr in enumerate(fieldCells))
+
+ if msgType == 'changecells' and len(msg[1]):
+ self.display.drawFieldCells(msg[1])
+
+ if msgType == "playerpos":
+ self.display.setFieldCenter(msg[1])
+
+ if msgType == "health":
+ health, maxHealth = msg[1]
+ self.display.setHealth(health, maxHealth)
+ if maxHealth is None:
+ self.log("You have died. Restart the client to respawn")
+ if msgType == "inventory":
+ self.display.setInventory(msg[1])
+ if msgType == "equipment":
+ self.display.setEquipment(msg[1])
+ if msgType == "ground":
+ self.display.setGround(msg[1])
+ if msgType == "message":
+ type, text = msg[1][:2]
+ self.log(text, type)
+ if msgType == "options":
+ if msg[1] != None:
+ description, options = msg[1]
+ self.log(description)
+ for option in options:
+ self.log(option)
+
+
+ def log(self, text, type=None):
+ if not isinstance(text, str):
+ text = str(text)
+ self.display.addMessage(text, type)
+ if self.logFile:
+ with(open(self.logFile, 'a')) as f:
+ f.write("[{}] {}\n".format(type or "", text))
+
+
+ def command_loop(self):
+ while self.keepalive:
+ self.display.update()
+ action = self.queue.get()
+ if action[0] == "message":
+ self.update(action[1])
+ elif action[0] == "input":
+ if action[1] == "^C":
+ raise KeyboardInterrupt
+ self.inputHandler.onInput(action[1])
+ elif action[0] == "error":
+ raise action[1]
+ elif action[0] == "sigwinch":
+ self.display.update_size()
+ else:
+ raise Exception("invalid action in queue")
+
+ def onSigwinch(self, signum, frame):
+ self.queue.put(("sigwinch", (signum, frame)))
+
+
+
+
diff --git a/asciifarmclient/inputhandler.py b/asciifarmclient/inputhandler.py
new file mode 100644
index 0000000..4281c01
--- /dev/null
+++ b/asciifarmclient/inputhandler.py
@@ -0,0 +1,98 @@
+
+import string
+
+from .commandhandler import CommandHandler, InvalidCommandException
+
+import ratuil.inputs as inp
+
+
+class InputHandler:
+
+ def __init__(self, client, keybindings):
+ self.client = client
+ self.keybindings = keybindings
+ self.commandHandler = CommandHandler(self.client)
+
+ self.typing = False
+ self.string = ""
+ self.cursor = 0
+
+
+ def onInput(self, key):
+ if not self.typing:
+ keyName = key
+ if keyName in self.keybindings:
+ self.commandHandler.execute(self.keybindings[keyName])
+ else:
+ self.addKey(key)
+
+
+ def processString(self, message):
+ if message:
+ if message[0] == '/':
+ if len(message) == 1:
+ return
+ if message[1] == '/':
+ self.commandHandler.chat(message[1:])
+ else:
+ try:
+ command, _sep, arg = message[1:].partition(' ')
+ try:
+ self.commandHandler.execute([command, arg])
+ except Exception as e:
+ self.log(e)
+ except InvalidCommandException as e:
+ self.client.log(", ".join(e.args))
+ else:
+ self.commandHandler.chat(message)
+
+ def startTyping(self, startText=""):
+ self.typing = True
+ if startText and not self.string:
+ self.string = startText
+ self.cursor = len(self.string)
+
+ self.showString()
+
+ def showString(self):
+ self.client.display.setInputString(self.string, self.cursor if self.typing else None)
+
+ def addKey(self, key):
+ if key == inp.BACKSPACE:
+ self.string = self.string[:self.cursor-1] + self.string[self.cursor:]
+ self.cursor = max(self.cursor - 1, 0)
+ elif key == inp.RIGHT:
+ self.cursor = min(self.cursor + 1, len(self.string))
+ elif key == inp.LEFT:
+ self.cursor = max(self.cursor - 1, 0)
+ elif key == inp.DELETE:
+ self.string = self.string[:self.cursor] + self.string[self.cursor+1:]
+ elif key == inp.HOME:
+ self.cursor = 0
+ elif key == inp.END:
+ self.cursor = len(self.string)
+
+ elif key == inp.ESCAPE:
+ # throw away entered string and go back to game
+ self.typing = False
+ self.string = ""
+ self.cursor = 0
+ elif key == inp.ENTER:
+ # process entered string and reset it
+ message = self.string
+ self.string = ""
+ self.cursor = 0
+ self.typing = False
+ self.processString(message)
+ elif key == "^I": # tab
+ # return to game but keep entered string
+ self.typing = False
+ elif key.isprintable() and len(key) == 1:
+ self.string = self.string[:self.cursor] + key + self.string[self.cursor:]
+ self.cursor += len(key)
+
+ self.showString()
+
+
+
+
diff --git a/asciifarmclient/layout.xml b/asciifarmclient/layout.xml
new file mode 100644
index 0000000..d50ecba
--- /dev/null
+++ b/asciifarmclient/layout.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0"?>
+<hbox>
+ <vbox width="20" align="right">
+ <textbox id="healthtitle" height="1" format="true">Health ({filled}/{total})</textbox>
+ <bar id="health" height="1" full-char="#" empty-char="_" full-style="fg:2; bg:2" empty-style="fg:1; bg: 1;"></bar>
+ <listing id="switchtitles" height="0"></listing>
+ <switchbox id="switch" height="50%">
+ <vbox key="inventory">
+ <textbox height="1">Inventory:</textbox>
+ <listing id="inventory">
+ milk
+ eggs
+ bread
+ </listing>
+ </vbox>
+ <vbox key="equipment">
+ <textbox height="1">Equipment:</textbox>
+ <listing id="equipment">
+ cotton underwear
+ cotton shirt
+ jeans
+ friendship bracelet
+ </listing>
+ </vbox>
+ <vbox key="ground">
+ <textbox height="1">Ground:</textbox>
+ <listing id="ground">
+ concrete
+ </listing>
+ </vbox>
+ </switchbox>
+ <textbox id="info" wrap="words"></textbox>
+ </vbox>
+ <vbox>
+ <textinput id="textinput" align="bottom" height="1">hello</textinput>
+ <log id="msg" align="bottom" height="20%%">
+ Welcome to asciifarm
+ </log>
+ <overlay>
+ <fill id="fieldbackground" char=" "></fill>
+ <field id="field"></field>
+ </overlay>
+ </vbox>
+</hbox>
diff --git a/asciifarmclient/listselector.py b/asciifarmclient/listselector.py
new file mode 100644
index 0000000..65e6e8c
--- /dev/null
+++ b/asciifarmclient/listselector.py
@@ -0,0 +1,49 @@
+
+from asciifarmclient.common import utils
+
+
+class ListSelector:
+
+ def __init__(self, widget):
+ self.widget = widget
+ self.items = []
+ self.selector = 0
+
+ def getSelected(self):
+ return self.selector
+
+ def select(self, value, relative=False, modular=False):
+ invLen = len(self.items)
+ if relative:
+ value += self.selector
+ if modular and invLen:
+ value %= invLen
+ if value < 0:
+ value = 0
+ if value >= invLen:
+ value = invLen-1
+ if value in range(invLen):
+ self.doSelect(value)
+
+ def doSelect(self, value):
+ self.selector = value
+ self.widget.select(value)
+
+ def setItems(self, items):
+ self.items = items
+ self.selector = utils.clamp(self.selector, 0, len(items)-1)
+ self.widget.set_items([self.itemName(item) for item in self.items])
+ self.widget.select(self.selector)
+
+ def getItem(self, num):
+ return self.items[num]
+
+ def getSelectedItem(self):
+ return self.getItem(self.getSelected())
+
+ def getNumItems(self):
+ return len(self.items)
+
+ def itemName(self, item):
+ return item
+
diff --git a/asciifarmclient/loaders.py b/asciifarmclient/loaders.py
new file mode 100644
index 0000000..efdd1c0
--- /dev/null
+++ b/asciifarmclient/loaders.py
@@ -0,0 +1,81 @@
+
+import os
+
+from .paths import keybindingsPath, charmapPath
+import json
+
+
+standardKeyFiles = {
+ "default": os.path.join(keybindingsPath, "default.json"),
+ "azerty": os.path.join(keybindingsPath, "azerty.json")
+}
+
+def loadKeybindings(name):
+ fname = None
+ if name in standardKeyFiles:
+ fname = standardKeyFiles[name]
+ else:
+ fname = name
+ with open(fname) as f:
+ data = json.load(f)
+ bindings = {}
+ help = ""
+ for ftemplate in data.get("templates", []):
+ if ftemplate.partition(os.sep)[0] in {".", ".."}:
+ ftemplate = os.path.relpath(ftemplate, fname)
+ template = loadKeybindings(ftemplate)
+ bindings.update(template.get("actions", {}))
+ help = template.get("help", help)
+ bindings.update(data.get("actions", {}))
+ help = data.get("help", help)
+ return {"actions": bindings, "help": help}
+
+
+standardCharFiles = {name: os.path.join(charmapPath, file) for name, file in {
+ "default": "fullwidth.json",
+ "halfwidth": "halfwidth.json",
+ "hw": "halfwidth.json",
+ "fullwidth": "fullwidth.json",
+ "fw": "fullwidth.json",
+ "emoji": "emoji.json"
+}.items()}
+
+def loadCharmap(name):
+ fname = None
+ if name in standardCharFiles:
+ fname = standardCharFiles[name]
+ else:
+ fname = name
+ with open(fname) as f:
+ data = json.load(f)
+
+ templates = []
+ for ftemplate in data.get("templates", []):
+ if ftemplate.partition(os.sep)[0] in {".", ".."}:
+ ftemplate = os.path.relpath(ftemplate, fname)
+ templates.append(loadCharmap(ftemplate))
+
+ templates.append(data)
+
+ mapping = {}
+ writable = {}
+ default = None
+ charwidth = 1
+ alphabet = ""
+ msgcolours = {}
+
+ for template in templates:
+ mapping.update(template.get("mapping", {}))
+ writable.update(template.get("writable", {}))
+ default = template.get("default", default)
+ charwidth = template.get("charwidth", charwidth)
+ alphabet = template.get("alphabet", alphabet)
+ msgcolours.update(template.get("msgcolours", {}))
+ return {
+ "mapping": mapping,
+ "writable": writable,
+ "default": default,
+ "charwidth": charwidth,
+ "alphabet": alphabet,
+ "msgcolours": msgcolours
+ }
diff --git a/asciifarmclient/main.py b/asciifarmclient/main.py
new file mode 100644
index 0000000..d720477
--- /dev/null
+++ b/asciifarmclient/main.py
@@ -0,0 +1,65 @@
+#! /usr/bin/python3
+
+import json
+
+import sys
+import termios
+import tty
+import signal
+#import os
+
+from .connection import Connection
+from .gameclient import Client
+from .display import Display
+from .parseargs import parse_args
+from ratuil.screen import Screen
+
+def main(argv=None):
+
+ (name, socketType, address, keybindings, characters, colours, logfile) = parse_args(argv)
+
+
+ connection = Connection(socketType)
+ try:
+ connection.connect(address)
+ except ConnectionRefusedError:
+ print("ERROR: Could not connect to server.\nAre you sure that the server is running and that you're connecting to the right address?", file=sys.stderr)
+ return
+
+ error = None
+ closeMessage = None
+
+ #os.environ.setdefault("ESCDELAY", "25")
+
+ fd = sys.stdin.fileno()
+ oldterm = termios.tcgetattr(fd)
+
+ try:
+
+ tty.setraw(sys.stdin)
+ Screen.default.hide_cursor()
+
+ display = Display(characters)
+ client = Client(display, name, connection, keybindings, logfile)
+ signal.signal(signal.SIGWINCH, client.onSigwinch)
+ try:
+ client.start()
+ except KeyboardInterrupt:
+ client.close("^C caught, goodbye")
+ except Exception as e:
+ # throw the execption outside ncurses
+ # so the cleanup can happen first
+ error = e
+ closeMessage = client.closeMessage
+ finally:
+ ## Set everything back to normal
+ termios.tcsetattr(fd, termios.TCSADRAIN, oldterm)
+ Screen.default.finalize()
+
+
+ if error is not None:
+ raise error
+
+ if closeMessage:
+ print(closeMessage, file=sys.stderr)
+
diff --git a/asciifarmclient/parseargs.py b/asciifarmclient/parseargs.py
new file mode 100644
index 0000000..a8b393b
--- /dev/null
+++ b/asciifarmclient/parseargs.py
@@ -0,0 +1,66 @@
+
+
+import argparse
+import getpass
+import json
+import os
+import os.path
+
+from . import loaders
+
+
+defaultAdresses = {
+ "abstract": "asciifarm",
+ "unix": "asciifarm.socket",
+ "inet": "localhost:9021",
+}
+
+def parse_args(argv):
+
+ parser = argparse.ArgumentParser(description="The client to AsciiFarm. Run this to connect to to the server.", epilog="""
+ Gameplay information:
+ Walk around and explore the rooms.
+ Kill the goblins and plant the seeds.
+
+ ~troido""", formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('-n', '--name', help='Your player name (must be unique!). Defaults to username on inet sockets and tildename on unix socket (including abstract). Apart from the tilde in a tildename all characters must be unicode letters, numbers or connection puctuation. The maximum size of a name is 256 bytes when encoded as utf8', default=None)
+ parser.add_argument("-a", "--address", help="The address of the socket. When the socket type is 'abstract' this is just a name. When it is 'unix' this is a filename. When it is 'inet' is should be in the format 'address:port', eg 'localhost:8080'. Defaults depends on the socket type")
+ parser.add_argument("-s", "--socket", help="the socket type. 'unix' is unix domain sockets, 'abstract' is abstract unix domain sockets and 'inet' is inet sockets. ", choices=["abstract", "unix", "inet"], default="abstract")
+ parser.add_argument('-k', '--keybindings', help='The file with the keybinding configuration. This file is a JSON file.', default="default")
+ parser.add_argument('-c', '--characters', help='The file with the character mappings for the graphics. If it is either of these names: {} it will be loaded from the charmaps directory.'.format(list(loaders.standardCharFiles.keys())), default="default")
+ parser.add_argument('-o', '--logfile', help='All game messages will be written to this file.', default=None)
+
+ colourGroup = parser.add_mutually_exclusive_group()
+ colourGroup.add_argument('-l', '--colours', '--colors', help='enable colours! :)', action="store_true")
+ colourGroup.add_argument('-b', '--nocolours', '--nocolors', help='disable colours! :)', action="store_true")
+
+ args = parser.parse_args(argv)
+
+ charmap = loaders.loadCharmap(args.characters)
+
+ keybindings = loaders.loadKeybindings(args.keybindings)
+
+ address = args.address
+ if address is None:
+ address = defaultAdresses[args.socket]
+ if args.socket == "abstract":
+ address = '\0' + address
+ elif args.socket == "inet":
+ hostname, sep, port = address.partition(':')
+ address = (hostname, int(port))
+
+ colours = True
+ if args.colours:
+ colours = True
+ elif args.nocolours:
+ colours = False
+
+ name = args.name
+ if name is None:
+ username = getpass.getuser()
+ if args.socket == "unix" or args.socket == "abstract":
+ name = "~"+username
+ else:
+ name = username
+
+ return (name, args.socket, address, keybindings, charmap, colours, args.logfile)
diff --git a/asciifarmclient/paths.py b/asciifarmclient/paths.py
new file mode 100644
index 0000000..407d54c
--- /dev/null
+++ b/asciifarmclient/paths.py
@@ -0,0 +1,7 @@
+
+import os.path
+
+clientPath = os.path.dirname(__file__)
+farmsPath = os.path.normpath(os.path.join(clientPath, ".."))
+charmapPath = os.path.join(farmsPath, "charmaps")
+keybindingsPath = os.path.join(farmsPath, "keybindings")