From dd07ff4d686f07cdc9736627dd0ef099ef5e4e4f Mon Sep 17 00:00:00 2001 From: troido Date: Fri, 6 Mar 2020 11:26:59 +0100 Subject: new directory structure for the separate client repo --- asciifarmclient/__init__.py | 0 asciifarmclient/__main__.py | 16 +++ asciifarmclient/commandhandler.py | 150 ++++++++++++++++++++++++++++ asciifarmclient/common/__init__.py | 0 asciifarmclient/common/messages.py | 157 +++++++++++++++++++++++++++++ asciifarmclient/common/tcommunicate.py | 32 ++++++ asciifarmclient/common/utils.py | 47 +++++++++ asciifarmclient/connection.py | 30 ++++++ asciifarmclient/display.py | 174 +++++++++++++++++++++++++++++++++ asciifarmclient/gameclient.py | 173 ++++++++++++++++++++++++++++++++ asciifarmclient/inputhandler.py | 98 +++++++++++++++++++ asciifarmclient/layout.xml | 44 +++++++++ asciifarmclient/listselector.py | 49 ++++++++++ asciifarmclient/loaders.py | 81 +++++++++++++++ asciifarmclient/main.py | 65 ++++++++++++ asciifarmclient/parseargs.py | 66 +++++++++++++ asciifarmclient/paths.py | 7 ++ 17 files changed, 1189 insertions(+) create mode 100644 asciifarmclient/__init__.py create mode 100644 asciifarmclient/__main__.py create mode 100644 asciifarmclient/commandhandler.py create mode 100644 asciifarmclient/common/__init__.py create mode 100644 asciifarmclient/common/messages.py create mode 100644 asciifarmclient/common/tcommunicate.py create mode 100644 asciifarmclient/common/utils.py create mode 100644 asciifarmclient/connection.py create mode 100644 asciifarmclient/display.py create mode 100644 asciifarmclient/gameclient.py create mode 100644 asciifarmclient/inputhandler.py create mode 100644 asciifarmclient/layout.xml create mode 100644 asciifarmclient/listselector.py create mode 100644 asciifarmclient/loaders.py create mode 100644 asciifarmclient/main.py create mode 100644 asciifarmclient/parseargs.py create mode 100644 asciifarmclient/paths.py (limited to 'asciifarmclient') diff --git a/asciifarmclient/__init__.py b/asciifarmclient/__init__.py new file mode 100644 index 0000000..e69de29 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 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 @@ + + + + Health ({filled}/{total}) + + + + + Inventory: + + milk + eggs + bread + + + + Equipment: + + cotton underwear + cotton shirt + jeans + friendship bracelet + + + + Ground: + + concrete + + + + + + + hello + + Welcome to asciifarm + + + + + + + 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") -- cgit