from PIL import Image
import xml.etree.ElementTree as ET
import quest
from itertools import product, chain
from pathlib import Path
from enum import Flag, auto
from math import sqrt
import re
[docs]def tint(color, ratio=0.25):
"""Creates a tint of a color by scaling it toward pure white.
Arguments:
color (int, int, int): The base color.
ratio (float): A value between 0 and 1. 0 would have no effect;
1 would be pure white.
"""
return tuple(round(255 - (255 - c) * (1 - ratio)) for c in color)
[docs]def shade(color, ratio=0.25):
"""Creates a shade of a color by scaling it toward pure black.
Arguments:
color (int, int, int): The base color.
ratio (float): A value between 0 and 1. 0 would have no effect;
1 would be pure black.
"""
return tuple(round(c * (1 - ratio)) for c in color)
[docs]class Direction(Flag):
"""Direction lets you talk about directions like `Direction.DOWN`, `Direction.UPLEFT`
or using compass directions such as `Direction.NE`.
"""
NONE = 0
RIGHT = auto()
UP = auto()
DOWN = auto()
LEFT = auto()
UPRIGHT = UP | RIGHT
UPLEFT = UP | LEFT
DOWNLEFT = DOWN | LEFT
DOWNRIGHT = DOWN | RIGHT
E = RIGHT
N = UP
S = DOWN
W = LEFT
NE = N | E
NW = N | W
SW = S | W
SE = S | E
PRIMARY_AXIS = LEFT | RIGHT
[docs] @classmethod
def from_vector(cls, vector, diagonal=True):
"""Converts an (x, y) tuple into a direction.
>>> Direction.from_vector((-1, 0))
Direction.LEFT
>>> Direction.from_vector((0.4, 0.6))
Direction.UPRIGHT
Arguments:
vector (float, float): An (x, y) tuple.
diagonal (bool): Whether to include diagonal directions. Defaults to True.
Returns:
A Direction.
"""
vx, vy = vector
result = cls.NONE
if vx < 0:
result |= cls.LEFT
if vx > 0:
result |= cls.RIGHT
if vy < 0:
result |= cls.DOWN
if vy > 0:
result |= cls.UP
if result.is_diagonal() and not diagonal:
result &= cls.PRIMARY_AXIS
return result
[docs] def is_diagonal(self):
"""Returns whether the Direction is diaognal.
>>> Direction.NW.is_diagonal()
True
"""
return self in [self.NE, self.NW, self.SW, self.SE]
[docs] def turn_clockwise(self):
"""Returns the direction 90 degrees clockwise. Only supports cardinal directions.
"""
return {
Direction.E: Direction.S,
Direction.S: Direction.W,
Direction.W: Direction.N,
Direction.N: Direction.E,
}[self]
[docs] def turn_anticlockwise(self):
"""Returns the direction 90 degrees anticlockwise. Only supports cardinal directions.
"""
return {
Direction.E: Direction.N,
Direction.N: Direction.W,
Direction.W: Direction.S,
Direction.S: Direction.E,
}[self]
[docs] def to_vector(self, normalized=False):
"""Converts the Direction into an (x, y) tuple.
Arugments:
normalized (bool): Whether to normalize the vector so that its
magintude is 1. Defaults to False, in which case x and y are
each either 0 or 1.
"""
vx, vy = 0, 0
if self & self.RIGHT:
vx += 1
if self & self.UP:
vy += 1
if self & self.LEFT:
vx -= 1
if self & self.DOWN:
vy -= 1
if normalized:
vx, vy = normalize((vx, vy))
return vx, vy
[docs]class SpriteListList:
"""Allows multiple SpriteLists to be treated as if they were a single SpriteList.
"""
def __init__(self, sprite_lists):
self.sprite_lists = sprite_lists
def chain_sprite_lists(self):
return chain.from_iterable(self.sprite_lists)
def __iter__(self):
return iter(self.chain_sprite_lists())
def update(self):
for sprite_list in self.chain_sprite_lists():
sprite_list.update()
[docs]def tileset_to_collection(image_path, tile_size, output_dir, name="tileset", create_tsx=True):
"""Splits a tileset image into separate files.
Quest relies on Arcade, which only works with collections of images.
A lot of game art is provided as a single tile image. This function can help split
it out.
Args:
image_path: Path to an image containing a grid of tiles.
tile_size: Size in pixels of each tile.
output_dir: Directory for output files (there may be a lot of them).
name: Name of the tileset.
create_tsx: If True, also creates a tsx file which can be opened as a
:doc:`Tileset <tiled:manual/editing-tilesets>` using
:doc:`Tiled <tiled:manual/introduction>`.
"""
img = Image.open(image_path)
width, height = img.size
cols, rows = width // tile_size, height // tile_size
for i, j in product(range(cols), range(rows)):
left = i * tile_size
upper = j * tile_size
right = left + tile_size
lower = upper + tile_size
tile = img.crop((left, upper, right, lower))
output_path = Path(output_dir) / "img_{}_{}.png".format(j, i)
tile.save(output_path)
if create_tsx:
tileset = empty_tileset(tile_size, cols, rows, name)
for ix, (j, i) in enumerate(product(range(rows), range(cols))):
add_tile(tileset, ix, "img_{}_{}.png".format(j, i), tile_size)
with open(Path(output_dir) / "{}.tsx".format(name), 'wb') as f:
ET.ElementTree(tileset).write(f, encoding="UTF-8", xml_declaration=True)
def empty_tileset(tile_size, cols, rows, name):
tileset = ET.Element("tileset")
tileset.set('version', "1.2")
tileset.set('tiledversion',"1.3.1")
tileset.set('name', name)
tileset.set('tilewidth', str(cols))
tileset.set('tileheight', str(rows))
tileset.set('tilecount', str(cols * rows))
tileset.set('columns', "0")
grid = ET.SubElement(tileset, "grid")
grid.set('orientation', "orthagonal")
grid.set('width', "1")
grid.set('height', "1")
return tileset
def add_tile(tileset, tile_id, img_path, tile_size):
tile_element = ET.SubElement(tileset, 'tile')
tile_element.set('id', str(tile_id) )
img_element = ET.SubElement(tile_element, 'image')
img_element.set('width', str(tile_size))
img_element.set('height', str(tile_size))
img_element.set('source', img_path)
def normalize(vector):
return scale(vector, 1)
def scale(vector, magnitude):
vx, vy = vector
old_magnitude = sqrt(vx * vx + vy * vy) if vx * vx + vy * vy else 0
factor = magnitude / old_magnitude
return vx * factor, vy * factor
[docs]class SimpleInkParser:
"""Parses a simple subset of Ink syntax into a JSON-like data structure.
The ink must meet the following constraints:
- Must be valid Ink.
- All content must be in a knot. Knots must be delimited
with three equal signs on either side of the knot name.
- The only syntax allowed is knot declarations, sticky choices
(+) and diverts (->). Diverts are only allowed following a sticky
choice.
"""
[docs] def parse(self, ink):
"""Reads a story written in a subset of Ink syntax (described above) and returns a
data structure of content and choices, also described above.
"""
dialogue = {}
knots = self.split_knots(ink)
for line_num, knot_name, knot_ink in knots:
content, options = self.parse_knot_ink(line_num, knot_ink)
if len(options) == 0:
raise ValueError("line {}: Knot {} has no options".format(line_num, knot_name))
if knot_name in dialogue.keys():
raise ValueError("line {}: Knot {} already defined".format(line_num, knot_name))
dialogue[knot_name] = {"content": content, "options": options}
return dialogue
[docs] def parse_knot_ink(self, line_num, ink):
"""Reads lines of code in a knot and returns a list of content and a dict of options.
"""
content, options = self.split_content_from_options(ink)
content = [c.strip() for c in content]
content = self.split_and_join(content, lambda c: not c)
parsed_content = [c.strip() for c in content if c.strip()]
options = [o.strip() for o in options]
options = self.split_and_join(options, lambda o: re.match("\s*\+", o))
parsed_options = {}
for option in options[1:]:
match = re.match("\s*\+(?P<text>.*)\->\s*(?P<knot>[a-zA-Z_]+)", option)
if not match:
raise ValueError("line {}: Error reading option in knot.".format(line_num))
parsed_options[match.group('text').strip()] = match.group('knot')
return parsed_content, parsed_options
[docs] def split_and_join(self, strings, condition):
"""Splits a list of strings on a condition and joins the results. For example,
>>> vowel = lambda l: l in 'aeiou'
>>> split_and_join('abcdefghijklmnop', vowel)
['a', 'bcde', 'fghi', 'jklmno', 'p']
"""
splits = [i for i, s in enumerate(strings) if condition(s)]
return [' '.join(strings[i:j]) for i, j in zip([0] + splits, splits + [len(strings)])]
def split_content_from_options(self, ink):
content, options = [], []
for line in ink:
if any(options) or re.match("\s*\+", line):
options.append(line)
else:
content.append(line)
return content, options
def split_knots(self, ink):
knots = []
current_name = None
current_ink = []
for i, line in enumerate(ink):
name = self.parse_knot_declaration(line)
# The first knot has not started yet
if name is None and current_name is None:
continue
# Starting the first knot
elif name is not None and current_name is None:
current_name = name
# Starting a new knot
elif name is not None:
if len(current_ink) == 0:
raise ValueError("Expected knot content at line {}".format(i))
starting_line_num = i - len(current_ink)
knots.append((starting_line_num, current_name, current_ink))
current_name = name
current_ink = []
# Continuing inside a knot
else:
current_ink.append(line)
# At end of file, the final knot's contents
if len(current_ink) == 0:
raise ValueError("Expected knot content at line {}".format(i))
starting_line_num = i - len(current_ink) - 1
knots.append((starting_line_num, current_name, current_ink))
return knots
[docs] def parse_knot_declaration(self, line):
"Returns knot name if found"
match = re.match("===\s+([a-zA-Z_]+)\s+===", line)
return match.group(1) if match else None
[docs]def resolve_resource_path(filename):
"Resolves a resource path for example game resouces (so they can be played from anywhere)."
return str(Path(quest.__file__).parent / "examples" / filename)