Files
fulpstation/tools/mapmerge2/dmi.py
Tad Hardesty 2106e3a1b2 Add a Python script to fix DMI conflicts
It hooks into git as a merge driver and automatically runs with merges.
It prints a log of what it did, and if any specific states are
conflicted it indicates them and does not mark the merge as successful.
The conflicting icon can then be opened in DreamMaker and the
conflicting states resolved there.
2018-05-21 15:37:52 -07:00

254 lines
7.7 KiB
Python

# Tools for working with modern DreamMaker icon files (PNGs + metadata)
import math
from PIL import Image
from PIL.PngImagePlugin import PngInfo
DEFAULT_SIZE = 32, 32
LOOP_UNLIMITED = 0
LOOP_ONCE = 1
NORTH = 1
SOUTH = 2
EAST = 4
WEST = 8
SOUTHEAST = SOUTH|EAST
SOUTHWEST = SOUTH|WEST
NORTHEAST = NORTH|EAST
NORTHWEST = NORTH|WEST
CARDINALS = [NORTH, SOUTH, EAST, WEST]
DIR_ORDER = [SOUTH, NORTH, EAST, WEST, SOUTHEAST, SOUTHWEST, NORTHEAST, NORTHWEST]
DIR_NAMES = {
'SOUTH': SOUTH,
'NORTH': NORTH,
'EAST': EAST,
'WEST': WEST,
'SOUTHEAST': SOUTHEAST,
'SOUTHWEST': SOUTHWEST,
'NORTHEAST': NORTHEAST,
'NORTHWEST': NORTHWEST,
**{str(x): x for x in DIR_ORDER},
**{x: x for x in DIR_ORDER},
'0': SOUTH,
None: SOUTH,
}
class Dmi:
version = "4.0"
def __init__(self, width, height):
self.width = width
self.height = height
self.states = []
@classmethod
def from_file(cls, fname):
image = Image.open(fname)
# no metadata = regular image file
if 'Description' not in image.info:
dmi = Dmi(*image.size)
state = dmi.state("")
state.frame(image)
return dmi
# read metadata
metadata = image.info['Description']
line_iter = iter(metadata.splitlines())
assert next(line_iter) == "# BEGIN DMI"
assert next(line_iter) == f"version = {cls.version}"
dmi = Dmi(*DEFAULT_SIZE)
state = None
for line in line_iter:
if line == "# END DMI":
break
key, value = line.lstrip().split(" = ")
if key == 'width':
dmi.width = int(value)
elif key == 'height':
dmi.height = int(value)
elif key == 'state':
state = dmi.state(unescape(value))
elif key == 'dirs':
state.dirs = int(value)
elif key == 'frames':
state._nframes = int(value)
elif key == 'delay':
state.delays = [parse_num(x) for x in value.split(',')]
elif key == 'loop':
state.loop = int(value)
elif key == 'rewind':
state.rewind = parse_bool(value)
elif key == 'hotspot':
x, y, frm = [int(x) for x in value.split(',')]
state.hotspot(frm - 1, x, y)
elif key == 'movement':
state.movement = parse_bool(value)
else:
raise NotImplementedError(key)
# cut image into frames
width, height = image.size
gridwidth = width // dmi.width
i = 0
for state in dmi.states:
for frame in range(state._nframes):
for dir in range(state.dirs):
px = dmi.width * (i % gridwidth)
py = dmi.height * (i // gridwidth)
im = image.crop((px, py, px + dmi.width, py + dmi.height))
assert im.size == (dmi.width, dmi.height)
state.frames.append(im)
i += 1
state._nframes = None
return dmi
def state(self, *args, **kwargs):
s = State(self, *args, **kwargs)
self.states.append(s)
return s
@property
def default_state(self):
return self.states[0]
def get_state(self, name):
for state in self.states:
if state.name == name:
return state
raise KeyError(name)
return self.default_state
def _assemble_comment(self):
comment = "# BEGIN DMI\n"
comment += f"version = {self.version}\n"
comment += f"\twidth = {self.width}\n"
comment += f"\theight = {self.height}\n"
for state in self.states:
comment += f"state = {escape(state.name)}\n"
comment += f"\tdirs = {state.dirs}\n"
comment += f"\tframes = {state.framecount}\n"
if state.framecount > 1 and len(state.delays): #any(x != 1 for x in state.delays):
comment += "\tdelay = " + ",".join(map(str, state.delays)) + "\n"
if state.loop != 0:
comment += f"\tloop = {state.loop}\n"
if state.rewind:
comment += "\trewind = 1\n"
if state.movement:
comment += "\tmovement = 1\n"
if state.hotspots and any(state.hotspots):
current = None
for i, value in enumerate(state.hotspots):
if value != current:
x, y = value
comment += f"\thotspot = {x},{y},{i + 1}\n"
current = value
comment += "# END DMI"
return comment
def to_file(self, filename, *, palette=False):
# assemble comment
comment = self._assemble_comment()
# assemble spritesheet
W, H = self.width, self.height
num_frames = sum(len(state.frames) for state in self.states)
sqrt = math.ceil(math.sqrt(num_frames))
output = Image.new('RGBA', (sqrt * W, math.ceil(num_frames / sqrt) * H))
i = 0
for state in self.states:
for frame in state.frames:
output.paste(frame, ((i % sqrt) * W, (i // sqrt) * H))
i += 1
# save
pnginfo = PngInfo()
pnginfo.add_text('Description', comment, zip=True)
if palette:
output = output.convert('P')
output.save(filename, 'png', optimize=True, pnginfo=pnginfo)
class State:
def __init__(self, dmi, name, *, loop=LOOP_UNLIMITED, rewind=False, movement=False, dirs=1):
self.dmi = dmi
self.name = name
self.loop = loop
self.rewind = rewind
self.movement = movement
self.dirs = dirs
self._nframes = None # used during loading only
self.frames = []
self.delays = []
self.hotspots = None
@property
def framecount(self):
if self._nframes is not None:
return self._nframes
else:
return len(self.frames) // self.dirs
def frame(self, image, *, delay=1):
assert image.size == (self.dmi.width, self.dmi.height)
self.delays.append(delay)
self.frames.append(image)
def hotspot(self, first_frame, x, y):
if self.hotspots is None:
self.hotspots = [None] * self.framecount
for i in range(first_frame, self.framecount):
self.hotspots[i] = x, y
def _frame_index(self, frame=0, dir=None):
ofs = DIR_ORDER.index(DIR_NAMES[dir])
if ofs >= self.dirs:
ofs = 0
return frame * self.dirs + ofs
def get_frame(self, *args, **kwargs):
return self.frames[self._frame_index(*args, **kwargs)]
def escape(text):
assert '\\' not in text and '"' not in text
return f'"{text}"'
def unescape(text, quote='"'):
if text == 'null':
return None
if not (text.startswith(quote) and text.endswith(quote)):
raise ValueError(text)
text = text[1:-1]
assert '\\' not in text and quote not in text
return text
def parse_num(value):
if '.' in value:
return float(value)
return int(value)
def parse_bool(value):
if value not in ('0', '1'):
raise ValueError(value)
return value == '1'
if __name__ == '__main__':
# test: can we load every DMI in the tree
import os
count = 0
for dirpath, dirnames, filenames in os.walk('.'):
if '.git' in dirnames:
dirnames.remove('.git')
for filename in filenames:
if filename.endswith('.dmi'):
Dmi.from_file(os.path.join(dirpath, filename))
count += 1
print(f"Successfully parsed {count} dmi files")