From b332d579e9296d7c5e1cde0619fadb1a8921b886 Mon Sep 17 00:00:00 2001 From: Patrick Marsee Date: Thu, 30 May 2019 15:44:29 -0400 Subject: [PATCH] Added simple scripting for yaml files. --- .gitignore | 1 + gamebase.py | 484 ++++++++++++++++++++++++++++++++++++++++- gamemap.py | 10 +- gameshell.py | 12 + testing/test1.yml | 1 + testing/testDialog.yml | 2 + 6 files changed, 506 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a81cb5e..7a2b14a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ maps saves __pycache__ gameshell.geany +gameexpr.py diff --git a/gamebase.py b/gamebase.py index a11febd..8bc63b8 100644 --- a/gamebase.py +++ b/gamebase.py @@ -25,6 +25,7 @@ class GameBase(object): self.__behaviors = {} self.__gameEvents = {} self.__IOCalls = {} # {str : function} + self.__scripts = {} # functions with the same signature, but not callable by the user self.customValues = {} # for setting flags and such self.level = None self.persist = {} # {level : {thingName : thing}} @@ -61,6 +62,11 @@ class GameBase(object): self.registerUseFunc('info', self.info) self.registerUseFunc('wear', self.wear) self.registerBehavior('wander', self.wander) + self.registerScript('if', self.ifScript) + self.registerScript('printf', self.printfScript) + self.registerScript('set', self.setCustomValue) + self.registerScript('spawn', self.spawnThing) + self.registerScript('give', self.giveToPlayer) # Helper functions @@ -175,6 +181,60 @@ Returns (thing, x, y). "Thing" can be None.""" return '\n'.join(ret) + def getValueFromString(self, arg: str): + val = None + validIdent = _re.match(r'[_A-Za-z][_0-9A-Za-z]*', arg) + if arg[0] in '"\'': # assume it's a string + val = arg[1:-1] + elif _re.match(r'[+-]?(?:[0-9]*[.])?[0-9]+', arg) != None: + if '.' in arg: + val = float(arg) + else: + val = int(arg) + elif arg.casefold() == 'true': + val = True + elif arg.casefold() == 'false': + val = False + elif arg.casefold() == 'playerx': + val = self.playerx + elif arg.casefold() == 'playery': + val = self.playery + elif arg.casefold() == 'playername': + val = self.playerName + elif arg.casefold() == 'playerinv': + val = self.playerInv + elif validIdent != None: + if 'scriptLocal' in self.customValues and validIdent.group() in self.customValues['scriptLocal']: + val = self.customValues['scriptLocal'][arg] + elif validIdent.group() in self.customValues: + val = self.customValues[arg] + else: + return False # for if statements; if a variable doesn't exist, should evaluate to False + # evaluate all values of all indecies + if validIdent.end() < len(arg): + if arg[validIdent.end()] == '[': + openBracket = validIdent.end() + ptr = openBracket + depth = 0 + while ptr < len(arg): + if ptr == '[': + depth += 1 + elif ptr == ']': + depth -= 1 + if depth == 0: + var = var[self.getValueFromString(arg[openBracket+1:ptr])] + openBracket = ptr + 1 + ptr += 1 + if depth == 0 and arg[ptr] != '[': + raise GameError('Invalid value syntax: {}'.format(arg)) + else: + raise GameError('Invalid value syntax: {}'.format(arg)) + else: + raise GameError('Invalid argument to getValueFromString: {}'.format(arg)) + return val + + # commands + def go(self, args): """go [-r] [to [the]] destination [additional arguments...] Go to a location. "walk" and "move" are aliases of go. @@ -486,6 +546,7 @@ Object can be the name of the object, or its coordinates.""" self.prevx, self.prevy = self.playerx, self.playery if self.onLevelLoad != None: self.onLevelLoad() + self.parseScript(self.level.enterScript) def saveGame(self, args): if len(args) < 1: @@ -541,13 +602,86 @@ Object can be the name of the object, or its coordinates.""" #_hq.heappush(self.eventQueue, (self.gameTime, _ge.NoOpEvent())) return - def dialog(self, dialogName, conversant): + def dialog(self, dialogName, conversant = None): yaml = _yaml.YAML() dialog = None with open(dialogName, 'r') as f: dialog = yaml.load(f) self.getIO('dialog')(dialog) + def parseScript(self, instr: str): + """parses then runs a script.""" + #print('parseScript called with {}.'.format(instr)) + if instr == '': + return # nothing to be done. + literalStr = list(instr) + inQuotes = False + script = [] + stack = [] + ret = [] + argStart = 0 + c = 0 + l = 0 + while c < len(instr): + if inQuotes: + if instr[c] == '"': + if instr[c-1] == '\\': + del literalStr[l-1] + l -= 1 + else: + inQuotes = False + del literalStr[l] + l -= 1 + else: + if instr[c] == '"': # quoted string + if l > 0 and instr[c-1] == '\\': + del literalStr[l-1] + l -= 1 + else: + inQuotes = True + del literalStr[l] + l -= 1 + elif instr[c] in ' \t\n;}{': + if argStart != l: + ret.append(''.join(literalStr[argStart:l])) + if instr[c] == ';': + script.append(ret) + ret = [] + elif instr[c] == '{': # block + stack.append(script) + script.append(ret) + script = [] + ret.append(script) + ret = [] + del literalStr[l] + l -= 1 + elif instr[c] == '}': # close block + if len(ret) > 0: + script.append(ret) + script = stack.pop() + ret = [] + del literalStr[l] + l -= 1 + argStart = l + 1 + c += 1 + l += 1 + if argStart != l: + ret.append(''.join(literalStr[argStart:l])) + script.append(ret) + #print('After parsing: {}'.format(script)) + self.customValues['scriptLocal'] = {} + self.runScript(script) + del self.customValues['scriptLocal'] + + def runScript(self, script: list): + """run a script""" + for line in script: + if line[0].casefold() == 'playercmd': + # run a player command + self.getIO('playercmd')(line[1:]) + else: + self.__scripts[line[0]](line[1:]) + def gameEventLoop(self): #print(self.skipLoop) if self.skipLoop: @@ -727,6 +861,350 @@ Object can be the name of the object, or its coordinates.""" print("The key doesn't fit that lock.", file = self.outstream) return 0.125 + # default scripts + + def ifScript(self, args): + """If statement: if [not] value [op value] script""" + if len(args) < 2: + raise GameError('Incomplete If statement: if {}'.format(' '.join(args))) + inverse = False + ret = False + if args[0] == 'not': + inverse = True + args.pop(0) + if len(args) < 2: + raise GameError('Incomplete If statement: if {}'.format(' '.join(args))) + + # evaluate condition + val = self.parseValue(args[0]) + if args[1] in ('==', '!=', '<=', '>=', '<', '>'): + if len(args) < 4: + raise GameError('Incomplete If statement: if {}'.format(' '.join(args))) + val2 = self.parseValue(args[2]) + if args[1] == '==': + ret = val == val2 + elif args[1] == '!=': + ret = val != val2 + elif args[1] == '<=': + ret = val <= val2 + elif args[1] == '>=': + ret = val >= val2 + elif args[1] == '<': + ret = val < val2 + elif args[1] == '>': + ret = val > val2 + args = args[3:] + else: + ret = bool(val) + args = args[1:] + if inverse: + ret = not ret + + # if condition is true, evaluate further + if ret: + if isinstance(args[-1], list): + self.runScript(args[-1]) + else: + self.runScript([args]) + + def printfScript(self, args): + """Assume the first argument is a stringable object, and format all others.""" + print(args[0].format(*[self.getValueFromString(i) for i in args[1:]]), file = self.outstream) + + def setCustomValue(self, args): + """takes [customValue, op, value]""" + env = self.customValues + if len(args) > 0 and args[0] == 'local': + if 'scriptLocal' in self.customValues: + env = self.customValues['scriptLocal'] + else: + raise GameError('Attempted to set a local variable without local scope.') + args.pop(0) + if len(args) < 3 or args[1] not in ('=', '+=', '-=', '*=', '/=', '%=', '//=', '**=', 'b=', '!=', '|=', '&=', '^='): + raise GameError('Arguments are not fit for the setCustomValue script.') + val = self.getValueFromString(args[2]) + + # set the value to a default value (0, 0.0, '', etc.) if not yet assigned + if args[0] not in env and args[1] != '=': + if isinstance(val, int): + env[args[0]] = 0 + elif isinstance(val, float): + env[args[0]] = 0.0 + elif isinstance(val, str): + env[args[0]] = '' + elif isinstance(val, bool): + env[args[0]] = False + # Beyond this point are types that can only be found from other customValues + elif isinstance(val, tuple): + env[args[0]] = () + elif isinstance(val, list): + env[args[0]] = [] + elif isinstance(val, dict): + env[args[0]] = {} + else: + raise GameError("Operation not supported for unassigned custom values of type {}: {}" + .format(type(val), args[1])) + + # done parsing, evaluate + if args[1] == '=': + env[args[0]] = val + elif args[1] == '+=': + env[args[0]] += val + elif args[1] == '-=': + env[args[0]] -= val + elif args[1] == '*=': + env[args[0]] *= val + elif args[1] == '/=': + env[args[0]] /= val + elif args[1] == '%=': + env[args[0]] %= val + elif args[1] == '//=': + env[args[0]] //= val + elif args[1] == '**=': + env[args[0]] **= val + elif args[1] == 'b=': + env[args[0]] = bool(val) + elif args[1] == '!=': + env[args[0]] = not bool(val) + elif args[1] == '|=': + env[args[0]] |= val + elif args[1] == '&=': + env[args[0]] &= val + elif args[1] == '^=': + env[args[0]] ^= val + + def spawnThing(self, args): + """Spawns a thing. [type name=... location=... etc]""" + if args[0].casefold() not in ('item', 'useable', 'npc', 'door', 'mapexit', 'mapentrance'): + raise GameError("{} not a spawnable thing type.".format(args[0])) + + # Get the standard fields that most Things have in common: name, coordinates, graphic. + name = None + x, y = -1, -1 + fgc, bgc, shape = None, None, None + persist = False + for i in args[1:]: + if i[0:5].casefold() == 'name=': + name = i[5:] + elif i[0:10].casefold() == 'location=': + _, x, y = self.parseCoords(i[10:].split()) + elif i[0:4].casefold() == 'fgc=': + if len(i[4:]) != 7 or i[0] != '#': + raise GameError("Invalid color: {}".format(i[4:])) + for j in i[4:]: + if j not in '0123456789abcdefABCDEF': + raise GameError("Invalid color: {}".format(i[4:])) + fgc = i[4:] + elif i[0:4].casefold() == 'bgc=': + if len(i[4:]) != 7 or i[0] != '#': + raise GameError("Invalid color: {}".format(i[4:])) + for j in i[4:]: + if j not in '0123456789abcdefABCDEF': + raise GameError("Invalid color: {}".format(i[4:])) + bgc = i[4:] + elif i[0:6].casefold() == 'shape=': + if len(i[6:]) != 7 or i[0] != '#': + raise GameError("Invalid color: {}".format(i[6:])) + for j in i[6:]: + if j not in '0123456789abcdefABCDEF': + raise GameError("Invalid color: {}".format(i[6:])) + shape = i[6:] + elif i == 'persist': + persist = True + if x < 0 or y < 0: + raise GameError("Cannot spawn thing: bad location") + + # Unfortunately, there needs to be a case for each type. + if args[0].casefold() == 'item': + # spawn an item + description = 'A nondescript item.' + useFunc = '' + useOnFunc = '' + customValues = {} + ranged = False + if name == None: + name = 'item {}'.format(self.nextThing) + if fgc == None: + fgc = _gm.Item.defaultGraphic['fgc'] + if bgc == None: + bgc = _gm.Item.defaultGraphic['bgc'] + if shape == None: + shape = _gm.Item.defaultGraphic['shape'] + for i in args[1:]: + if i[0:12].casefold() == 'description=': + description = i[12:] + elif i[0:8].casefold() == 'usefunc=': + useFunc = i[8:] + elif i[0:10].casefold() == 'useonfunc=': + useOnFunc = i[10:] + elif i.casefold() == 'ranged': + ranged = True + elif i[0:3].casefold() == 'cv:': + equalsign = i.index('=') + cv = i[3:equalsign] + customValues[cv] = getValueFromString(i[equalsign+1:]) + thing = _gm.Item(name, x, y, description, useFunc, useOnFunc, customValues, ranged, (bgc, fgc, shape)) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + elif args[0].casefold() == 'useable': + # spawn a useable thing + description = 'A nondescript useable thing.' + useFunc = '' + customValues = {} + playerx, playery = x, y + if name == None: + name = 'useable {}'.format(self.nextThing) + if fgc == None: + fgc = _gm.Item.defaultGraphic['fgc'] + if bgc == None: + bgc = _gm.Item.defaultGraphic['bgc'] + if shape == None: + shape = _gm.Item.defaultGraphic['shape'] + for i in args[1:]: + if i[0:12].casefold() == 'description=': + description = i[12:] + elif i[0:8].casefold() == 'usefunc=': + useFunc = i[8:] + elif i[0:10].casefold() == 'playerpos=': + _, playerx, playery = self.parseCoords(i[10:].split()) + elif i[0:3].casefold() == 'cv:': + equalsign = i.index('=') + cv = i[3:equalsign] + customValues[cv] = getValueFromString(i[equalsign+1:]) + thing = _gm.Useable(name, x, y, description, useFunc, customValues, playerx, playery, (bgc, fgc, shape)) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + elif args[0].casefold() == 'npc': + # spawn an NPC + description = 'A nondescript character.' + behavior = '' + inv = [] + customValues = {} + playerx, playery = x, y + if name == None: + name = 'character {}'.format(self.nextThing) + if fgc == None: + fgc = _gm.Item.defaultGraphic['fgc'] + if bgc == None: + bgc = _gm.Item.defaultGraphic['bgc'] + if shape == None: + shape = _gm.Item.defaultGraphic['shape'] + for i in args[1:]: + if i[0:12].casefold() == 'description=': + description = i[12:] + elif i[0:9].casefold() == 'behavior=': + useFunc = i[9:] + elif i[0:10].casefold() == 'playerpos=': + _, playerx, playery = self.parseCoords(i[10:].split()) + elif i[0:3].casefold() == 'cv:': + equalsign = i.index('=') + cv = i[3:equalsign] + customValues[cv] = getValueFromString(i[equalsign+1:]) + thing = _gm.NPC(name, x, y, description, behavior, inv, customValues, playerx, playery, False, (bgc, fgc, shape)) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + elif args[0].casefold() == 'door': + # spawn a door + description = 'a nondescript door.' + locked = False + key = None + if name == None: + name = 'door {}'.format(self.nextThing) + if fgc == None: + fgc = _gm.Item.defaultGraphic['fgc'] + if bgc == None: + bgc = _gm.Item.defaultGraphic['bgc'] + if shape == None: + shape = _gm.Item.defaultGraphic['shape'] + for i in args[1:]: + if i[0:12].casefold() == 'description=': + description = i[12:] + elif i.casefold() == 'locked': + locked = True + thing = _gm.Door(name, x, y, locked, description, key, (bgc, fgc, shape)) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + elif args[0].casefold() == 'mapexit': + # spawn an exit to another map (use with EXTREME caution!) + destination = '' + prefix = None + exitid = 0 + if name == None: + name = 'somewhere' + if fgc == None: + fgc = _gm.Item.defaultGraphic['fgc'] + if bgc == None: + bgc = _gm.Item.defaultGraphic['bgc'] + if shape == None: + shape = _gm.Item.defaultGraphic['shape'] + for i in args[1:]: + if i[0:12].casefold() == 'destination=': + destination = i[12:] + elif i[0:7].casefold() == 'prefix=': + prefix = i[7:] + elif i[0:7].casefold() == 'exitid=': + exitid = int(i[7:]) + thing = _gm.MapExit(name, x, y, exitid, destination, prefix, (bgc, fgc, shape)) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + elif args[0].casefold() == 'mapentrance': + # spawn a map entrance + exitid = 0 + for i in args[1:]: + if i[0:7].casefold() == 'exitid=': + exitid = int(i[7:]) + thing = _gm.MapEntrance(x, y, exitid, name) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + + def giveToPlayer(self, args): + """We get to assume it's an item.""" + name = 'item {}'.format(self.nextThing) + x, y = self.playerx, self.playery + fgc, bgc, shape = _gm.Item.defaultGraphic['fgc'], _gm.Item.defaultGraphic['bgc'], _gm.Item.defaultGraphic['shape'] + description = 'A nondescript item.' + persist = True + useFunc = '' + useOnFunc = '' + customValues = {} + ranged = False + for i in args[1:]: + if i[0:5].casefold() == 'name=': + name = i[5:] + elif i[0:10].casefold() == 'location=': + _, x, y = self.parseCoords(i[10:].split()) + elif i[0:4].casefold() == 'fgc=': + if len(i[4:]) != 7 or i[0] != '#': + raise GameError("Invalid color: {}".format(i[4:])) + for j in i[4:]: + if j not in '0123456789abcdefABCDEF': + raise GameError("Invalid color: {}".format(i[4:])) + fgc = i[4:] + elif i[0:4].casefold() == 'bgc=': + if len(i[4:]) != 7 or i[0] != '#': + raise GameError("Invalid color: {}".format(i[4:])) + for j in i[4:]: + if j not in '0123456789abcdefABCDEF': + raise GameError("Invalid color: {}".format(i[4:])) + bgc = i[4:] + elif i[0:6].casefold() == 'shape=': + if len(i[6:]) != 7 or i[0] != '#': + raise GameError("Invalid color: {}".format(i[6:])) + for j in i[6:]: + if j not in '0123456789abcdefABCDEF': + raise GameError("Invalid color: {}".format(i[6:])) + shape = i[6:] + elif i == 'persist': + persist = True + elif i[0:12].casefold() == 'description=': + description = i[12:] + elif i[0:8].casefold() == 'usefunc=': + useFunc = i[8:] + elif i[0:10].casefold() == 'useonfunc=': + useOnFunc = i[10:] + elif i.casefold() == 'ranged': + ranged = True + elif i[0:3].casefold() == 'cv:': + equalsign = i.index('=') + cv = i[3:equalsign] + customValues[cv] = getValueFromString(i[equalsign+1:]) + thing = _gm.Item(name, x, y, description, useFunc, useOnFunc, customValues, ranged, (bgc, fgc, shape)) + self.nextThing = self.level.addThing(thing, self.nextThing, persist) + # behaviors def wander(self, actor): @@ -752,6 +1230,10 @@ always give the player a turn, False otherwise.""" """Registers a function for useFuncs and such to use.""" self.__IOCalls[name] = func + def registerScript(self, name, func): + """Registers a function as a script callable from yaml files.""" + self.__scripts[name] = func + def getIO(self, name): """This is so derived classes can access their IOCalls.""" if name not in self.__IOCalls: diff --git a/gamemap.py b/gamemap.py index f79d0ea..3d442e1 100644 --- a/gamemap.py +++ b/gamemap.py @@ -411,11 +411,12 @@ class MapExit(Thing): class MapEntrance(Thing): yaml_flag = u'!MapEntrance' + defaultGraphic = ('clear', 'clear', 'x') # no graphic - should not be drawn - def __init__(self, x: int, y: int, exitid: int): - if prefix: - description = "{0} {1}".format(prefix, name) + def __init__(self, x: int, y: int, exitid: int, name = None): + if name == None: + name = 'entrance {}'.format(exitid) super(MapEntrance, self).__init__('a', name, x, y, description, 1) self.exitid = exitid @@ -458,6 +459,7 @@ class GameMap(object): self.floorColors = [] self.wallColors = [] self.persistent = [] + self.enterScript = '' @staticmethod def __cleanStr(text: str, end = '\n'): @@ -674,6 +676,8 @@ list of lists of tuples.""" level.playerStart = info['playerStart'] if 'description' in info: level.description = info['description'] + if 'enterScript' in info: + level.enterScript = info['enterScript'] # get map colors if 'floorColors' in info: diff --git a/gameshell.py b/gameshell.py index ee205af..65d91ef 100644 --- a/gameshell.py +++ b/gameshell.py @@ -155,6 +155,8 @@ If -l is given, a map legend will be printed under the map.""" elif thing.thingType == 'i': # item items[len(items)+1] = (thing.name, thing.graphic[1]) rows[-1].append('I{0}'.format(len(items))) + elif thing.thingType == 'a': # entrance + rows[-1].append(' ') elif pos[0] == 'w': if level.wallColors[level.mapMatrix[y][x][1]] != textColor: textColor = level.wallColors[level.mapMatrix[y][x][1]] @@ -210,6 +212,9 @@ If -l is given, a map legend will be printed under the map.""" ret.append("Event queue:") for i in sorted(self.gameBase.eventQueue): ret.append("{0:.<8.3}:{1:.>72}".format(i[0], str(i[1]))) + ret.append("custom values:") + for i in self.gameBase.customValues: + ret.append("{0:<22}: {1}".format(i, self.gameBase.customValues[i])) ret.append("Player name:{0:.>68}".format(self.gameBase.playerName)) ret.append("Player position:{0:.>64}".format("{0}{1}".format(self.gameBase.numberToLetter(self.gameBase.playerx), self.gameBase.playery))) ret.append("Prev. position:{0:.>65}".format("{0}{1}".format(self.gameBase.numberToLetter(self.gameBase.prevx), self.gameBase.prevy))) @@ -230,6 +235,8 @@ If -l is given, a map legend will be printed under the map.""" def inv(self, args): print('\n'.join([self.gameBase.playerInv[i].name for i in self.gameBase.playerInv])) + # IO calls + def container(self, inv, cont): """container IO""" # Pretty print: get length of the longest inventory item's name @@ -310,6 +317,8 @@ If -l is given, a map legend will be printed under the map.""" def dialog(self, dialogObj): if 'opener' in dialogObj: print(_tw.fill(dialogObj['opener'], width = TERM_SIZE)) + if 'script' in dialogObj: + self.gameBase.parseScript(dialogObj['script']) while isinstance(dialogObj, dict): if 'action' in dialogObj: action = dialogObj['action'] @@ -333,6 +342,9 @@ If -l is given, a map legend will be printed under the map.""" return int(action[4:]) elif action == 'exit': return 0 + + def playercmd(self, args): + self.handleCommand(args) def update(self): self.gameBase.gameEventLoop() diff --git a/testing/test1.yml b/testing/test1.yml index 8aef392..0a3a2dd 100644 --- a/testing/test1.yml +++ b/testing/test1.yml @@ -2,6 +2,7 @@ --- openingText: Floor 1 map loaded successfully. Normally, this would describe the environment. playerStart: [5, 26] +enterScript: printf "This script ran when you entered the level." layout: | w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w0 w w0 diff --git a/testing/testDialog.yml b/testing/testDialog.yml index 80dabcf..42f7d0d 100644 --- a/testing/testDialog.yml +++ b/testing/testDialog.yml @@ -13,3 +13,5 @@ replies: - opener: I would, but there's still a bunch of foreshadowing and stuff we have to get through first. action: back + script: set tellmore += 1; + printf "You have seen this text {} times." tellmore