wesnoth/data/tools/trackplacer

619 lines
25 KiB
Python
Executable File

#!/usr/bin/env python
"""
trackplacer -- map journey track editor.
usage: trackplacer [-vh?] [filename]
If the filename is not specified, trackplacer will enter a loop in which it
repeatedly pops up a file selector. Canceling the file selecct ends the
program; Selecting a file takes you to a main screen. For command help on
the main screen, click the Help button.
Can be started with a map image, in which case we're editing a new journey.
Can be started with a track file. A track file is a text file interpreted
line-by-line; each line is interpreted as whitespace-separated fields.
The first field of the first line must be MAP, and the second field
of that line must be valid filename. Subsequent lines must have three
fields each: an action tag (JOURNEY, BATTLE, or REST) and two numeric
coordinate fields.
A journey is an object containing a map file name and a (possibly empty)
track. This program exists to visually edit journeys represented in track
files.
Normally, trackplacer assumes it is running within a Battle for
Wesnoth source tree and changes directory to the root of the
tree. Paths saved in track files are relative to the tree root. All
pathnames in help and error messages are also relativized to that
root.
The -v option enables verbose logging to standard error.
The -d option sets the root directory to use.
The -h or -? options display this summary.
"""
gui_help = '''\
This is trackplacer, an editor for visually editing journey tracks on Battle For Wesnoth maps.
The radio buttons near the top left corner control which icon is placed by a left click, except that the when the trashcan is selected a left click deletes already-placed icons.
The Save button pops up a file selector asking you to supply a filename to which the track should be saved. If you specify a file with a .trk extension the data will be saved in track format, which trackplacer can reload or edit. Any other file extension will raise an error.
The Quit button will ask for confirmation if you have unsaved changes.
The Help button displays this message.
Design and implementation by Eric S. Raymond, October 2008.
'''
import sys, os, time, exceptions, getopt
import pygtk
pygtk.require('2.0')
import gtk
import wesnoth.wmltools
# All dependencies on the shape of the data tree live here
# The code does no semantic interpretation of these icons at all;
# to add new ones, just fill in a dictionary entry.
imagedir = "data/core/images/"
default_map = imagedir + "maps/wesnoth.png"
icon_dictionary = {
"JOURNEY": imagedir + "misc/new-journey.png",
"BATTLE": imagedir + "misc/new-battle.png",
"REST": imagedir + "misc/flag-red.png",
}
icon_presentation_order = ("JOURNEY", "BATTLE", "REST")
segmenters = ("BATTLE","REST")
# Size of the rectangle around the mouse pointer within which the code
# will seek tracking dots. Should be about (N-1)/2 where N is the
# pixel radius of the largest icon. For this and other reasons, it's
# helpful if the tracking dot and other icons all have a square
# aspect ratio and an odd number of pixels on the sise, so each has
# a well-defined center pixel.
vision_distance = 12
class IOException(exceptions.Exception):
"Exception thrown while reading a track file."
def __init__(self, message, path, lineno=None):
self.message = message
self.path = path
self.lineno = lineno
class JourneyTrack:
"Represent a journey track on a map."
def __init__(self):
self.mapfile = None # Map background of the journey
self.track = [] # List of (action, x, y) tuples
self.modifications = 0
self.initial_track = []
def write(self, fp):
"Record a journey track."
if fp.name.endswith(".trk"):
fp.write("MAP %s\n" % self.mapfile)
for location in self.track:
fp.write("%s %d %d\n" % location)
fp.close()
elif fp.name.endswith(".cfg"):
fp.write("# Automatically generated by trackplacer on %s.\n" % \
time.ctime(time.time()))
fp.write("# Don't hand-hack -- edit the associated track file\n")
fp.write("# and then regenerate this instead.\n\n")
index_tuples = zip(range(len(self.track)), self.track)
index_tuples = filter(lambda (i, (a, x, y)): a in segmenters,
index_tuples)
endpoints = map(lambda (i, t): i, index_tuples)
if self.track[-1][0] not in segmenters:
endpoints.append(len(self.track)-1)
for (i, e) in enumerate(endpoints):
fp.write("#define STAGE_%d\n" % (i+1,))
for j in range(0, e+1):
age="OLD"
if i == 0 or j > endpoints[i-1]:
age = "NEW"
waypoint = (age,) + tuple(self.track[j])
fp.write(" {%s_%s %d %d}\n" % waypoint)
fp.write("#enddef\n\n")
fp.write("#define STAGE_COMPLETE\n")
for j in range(len(self.track)):
waypoint = self.track[j]
fp.write(" {OLD_%s %d %d}\n" % tuple(waypoint))
fp.write("#enddef\n\n")
fp.close()
else:
raise IOException("File must have a .trk or .cfg extension.", fp.name)
def read(self, fp):
"Initialize a journey from map and track information."
if type(fp) == type(""):
try:
fp = open(fp)
except IOError:
raise IOException("Cannot read file.", fp)
if self.track:
raise IOException("Reading with track nonempty.", fp.name)
if fp.name.endswith(".png") or fp.name.endswith(".jpg"):
self.mapfile = fp.name
return
if not fp.name.endswith(".trk"):
raise IOException("Cannot read this filetype.", fp.name)
header = fp.readline().split()
if header[0] != 'MAP':
raise IOException("Missing MAP element.", fp.name, 1)
else:
self.mapfile = header[1]
for (i, line) in enumerate(fp):
fields = line.split()
if len(fields) != 3:
raise IOException("Ill-formed track file line.", fp.name, i+1)
(tag, x, y) = fields
if tag not in ("JOURNEY", "BATTLE", "REST"):
raise IOException("Invalid tag field on track file line.", fp.name, i+1)
try:
x = int(x)
y = int(y)
except ValuError:
raise IOException("Invalid coordinate field.", fp.name, i+1)
self.initial_track.append((tag, x, y))
fp.close()
self.track = self.initial_track[:]
def has_unsaved_changes(self):
return self.track != self.initial_track
def __getitem__(self, n):
return self.track[n]
def __setitem__(self, n, v):
self.track[n] = v
def find(self, x, y, d=vision_distance):
"Find all track actions near a given point."
candidates = []
ind = 0
for (tag, xt, yt) in self.track:
if x >= xt - d and x <= xt + d and y >= yt - d and y <= yt + d:
candidates.append(ind)
ind += 1
return candidates
def append(self, tag, x, y):
"Append a feature to the track."
self.track.append((tag, x, y))
def remove(self, x, y):
"Remove a feature from the track."
ind = self.find(x, y)
print "Deleting ", ind
if ind:
# Prefer to delete the most recent feature
self.track = self.track[:ind[-1]] + self.track[ind[-1]+1:]
def snap_to(self, x, y, d=vision_distance):
"Snap a location to a nearby track feature, if there is one."
candidates = []
for (tag, xt, yt) in self.track:
if x >= xt - d and x <= xt + d and y >= yt - d and y <= yt + d:
candidates.append((xt, yt))
if len(candidates) == 1:
return candidates[0]
else:
return None
def __str__(self):
return self.mapfile + ": " + `self.track`
class ModalFileSelector:
def __init__(self, default, legend):
self.default = default
self.path = None
# Create a new file selection widget
self.filew = gtk.FileSelection(legend)
self.filew.set_modal(True);
self.filew.ok_button.connect("clicked", self.selection_ok)
self.filew.cancel_button.connect("clicked", self.selection_canceled)
if self.default:
self.filew.set_filename(self.default)
self.filew.run()
def selection_canceled(self, widget):
self.path = None
self.filew.destroy()
def selection_ok(self, widget):
self.path = self.filew.get_filename()
if self.path.startswith(os.getcwd()):
self.path = self.path[len(os.getcwd())+1:]
self.filew.destroy()
class TrackEditorIcon:
def __init__(self, action, path):
self.action = action
# We need an image for the toolbar...
self.image = gtk.Image()
self.image.set_from_file(path)
# ...and a pixbuf for drawing on the map with.
self.icon = gtk.gdk.pixbuf_new_from_file(path)
self.icon_width = self.icon.get_width()
self.icon_height = self.icon.get_height()
class TrackEditor:
def __init__(self, path=None, verbose=False):
self.verbose = verbose
# Initialize our info about the map and track
self.journey = JourneyTrack()
self.last_read = None
self.journey.read(path)
if path.endswith(".trk"):
self.last_read = path
self.log("Initial track is %s" % self.journey)
self.action = "JOURNEY"
# Backing pixmap for drawing area
self.pixmap = None
# Grab the map into a pixmap
self.log("about to read map %s" % self.journey.mapfile)
try:
self.map = gtk.gdk.pixbuf_new_from_file(self.journey.mapfile)
self.map_width = self.map.get_width()
self.map_height = self.map.get_height()
self.map = self.map.render_pixmap_and_mask()[0]
except:
self.fatal_error("Error while reading background map %s" % self.journey.mapfile)
# Now get the icons we'll need for scribbling on the map with.
self.action_dictionary = {}
try:
for (action, path) in icon_dictionary.items():
icon = TrackEditorIcon(action, path)
self.log("%s icon has size %d, %d" % \
(action, icon.icon_width, icon.icon_height))
self.action_dictionary[action] = icon
except:
self.fatal_error("error while reading icons")
# Window-layout time
window = gtk.Window(gtk.WINDOW_TOPLEVEL)
window.set_name ("trackplacer")
vbox = gtk.VBox(False, 0)
window.add(vbox)
vbox.show()
window.connect("destroy", lambda w: gtk.main_quit())
# FIXME: make thw control box fixed-size
controls = gtk.HBox()
vbox.add(controls)
controls.show()
# The radiobutton array on the left
radiobox = gtk.HBox()
controls.pack_start(radiobox, expand=False, fill=False, padding=0)
radiobox.show()
# Fake icon-labeled buttons with liberal use of labels...
basebutton = None
for action in icon_presentation_order:
icon = self.action_dictionary[action]
button = gtk.RadioButton(basebutton)
if not basebutton:
button.set_active(True)
basebutton = button
button.connect("toggled", self.button_callback, icon.action)
radiobox.pack_start(button, expand=True, fill=True, padding=0)
button.show()
radiobox.pack_start(icon.image,
expand=False, fill=False, padding=0)
icon.image.show()
spacer = gtk.Label(" ")
radiobox.pack_start(spacer, expand=False, fill=False, padding=0)
spacer.show()
# The delete button and its label
button = gtk.RadioButton(button)
button.connect("toggled", self.button_callback, "DELETE")
radiobox.pack_start(button, expand=True, fill=True, padding=0)
button.show()
delimage = gtk.Image()
delimage.set_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_SMALL_TOOLBAR)
radiobox.pack_start(delimage,
expand=False, fill=False, padding=0)
delimage.show()
spacer = gtk.Label(" ")
radiobox.pack_start(spacer, expand=False, fill=False, padding=0)
spacer.show()
# The coordinate display in the middle
self.coordwin = gtk.Label()
controls.pack_start(self.coordwin, expand=True, fill=False, padding=0)
self.coordwin.show()
# The button array on the right
buttonbox = gtk.HBox()
controls.pack_end(buttonbox, expand=False, fill=False, padding=0)
buttonbox.show()
# A quit button
button = gtk.Button("Quit")
buttonbox.pack_end(button, expand=False, fill=False, padding=10)
button.connect_object("clicked", self.conditional_quit, window)
button.show()
# A save button
button = gtk.Button("Save")
buttonbox.pack_end(button, expand=False, fill=False, padding=10)
button.connect_object("clicked", self.save_handler, window)
button.show()
# A help button
button = gtk.Button("Help")
buttonbox.pack_end(button, expand=False, fill=False, padding=10)
button.connect_object("clicked", self.help_handler, window)
button.show()
# Create the drawing area on a viewport that scrolls if needed.
# Most of the hair here is in trying to query the height
# and depth of the widgets surrounding the scrolling area.
# The window frame size constants are guesses; they can vary
# according to your window manager's policy. They're only used
# if the map is too large to fit on the screen.
WINDOW_FRAME_WIDTH = 8
WINDOW_FRAME_HEIGHT = 28
screen_width = gtk.gdk.screen_width()
screen_height = gtk.gdk.screen_height()
self.log("Map size = (%d,%d)" % (self.map_width, self.map_height))
self.log("Screen size = (%d,%d)" % (screen_width, screen_height))
controls_width, controls_height = controls.size_request()
self.log("Control box size = (%d,%d)"%(controls_width, controls_height))
x_frame_width = WINDOW_FRAME_WIDTH
y_frame_width = WINDOW_FRAME_HEIGHT + controls_height
# No, I don't know why the +2 is needed. Black magic....
s_w = min(screen_width -x_frame_width, self.map_width+2)
s_h = min(screen_height-y_frame_width, self.map_height+2)
self.log("Scroller size = (%s,%s)" % (s_w, s_h))
scroller = gtk.ScrolledWindow()
scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
scroller.set_size_request(s_w, s_h)
self.drawing_area = gtk.DrawingArea()
self.drawing_area.set_size_request(self.map_width, self.map_height)
scroller.add_with_viewport(self.drawing_area)
vbox.pack_start(scroller, expand=True, fill=True, padding=0)
self.drawing_area.show()
scroller.show()
# Signals used to handle backing pixmap
self.drawing_area.connect("expose_event", self.expose_event)
self.drawing_area.connect("configure_event", self.configure_event)
# Event signals
self.drawing_area.connect("motion_notify_event", self.motion_notify_event)
self.drawing_area.connect("button_press_event", self.button_press_event)
self.drawing_area.connect("leave_notify_event",
lambda w, e: self.coordwin.set_text(""))
self.drawing_area.set_events(gtk.gdk.EXPOSURE_MASK
| gtk.gdk.LEAVE_NOTIFY_MASK
| gtk.gdk.BUTTON_PRESS_MASK
| gtk.gdk.POINTER_MOTION_MASK
| gtk.gdk.POINTER_MOTION_HINT_MASK)
window.show()
gtk.main()
self.log("initialization successful")
def button_callback(self, widget, data=None):
"Radio button callback, changes selected editing action."
if widget.get_active():
self.action = data
def refresh_map(self, x=0, y=0, xs=-1, ys=-1):
"Refresh part of the drawing area with the apprpriate map rectangle."
if xs == -1:
xs = self.map_width - x
if ys == -1:
ys = self.map_height - y
self.pixmap.draw_drawable(self.default_gc, self.map, x, y, x, y, xs, ys)
def draw_feature(self, widget, x, y, action, erase=False):
"Draw specified icon on the map."
icon = self.action_dictionary[action]
if erase:
# The +1 is a slop factor allowing for even-sized icons
rect = (x-icon.icon_width/2, y-icon.icon_height/2,
icon.icon_width+1, icon.icon_height+1)
self.log("Erasing action=%s, dest=%s" % (icon.action, rect))
self.refresh_map(*rect)
else:
rect = (x-icon.icon_width/2, y-icon.icon_height/2,
icon.icon_width, icon.icon_height)
self.log("Drawing action=%s, dest=%s" % (icon.action, rect))
self.pixmap.draw_pixbuf(self.default_gc, icon.icon, 0, 0, *rect)
widget.queue_draw_area(*rect)
def configure_event(self, widget, event):
"Create a new backing pixmap of the appropriate size."
x, y, width, height = widget.get_allocation()
self.pixmap = gtk.gdk.Pixmap(widget.window, width, height)
self.default_gc = self.drawing_area.get_style().fg_gc[gtk.STATE_NORMAL]
self.refresh_map()
for (action, x, y) in self.journey.track:
self.draw_feature(widget, x, y, action)
return True
def expose_event(self, widget, event):
"Redraw the screen from the backing pixmap"
x , y, width, height = event.area
widget.window.draw_drawable(self.default_gc,
self.pixmap, x, y, x, y, width, height)
return False
def button_press_event(self, widget, event):
if event.button == 1 and self.pixmap != None:
x = int(event.x)
y = int(event.y)
feature = self.journey.snap_to(x, y)
# Skip the redraw in half the cases
self.log("Action %s at (%d, %d): feature = %s" % (self.action, x, y, feature))
if (feature == None) and (self.action == "DELETE"):
return
if (feature != None) and (self.action != "DELETE"):
return
# Actual drawing and mutation of the journey track happens here
if not feature and self.action != "DELETE":
self.draw_feature(widget, x, y, self.action)
self.journey.track.append((self.action, x, y))
elif feature and self.action == "DELETE":
candidates = self.journey.find(x, y)
if len(candidates) == 1:
# Prefer to delete the most recent candidate
(action, x, y) = self.journey.track[candidates[-1]]
self.draw_feature(widget, x, y, action, erase=True)
self.journey.remove(x, y)
self.log("Track is %s" % self.journey)
return True
def motion_notify_event(self, widget, event):
if event.is_hint:
x, y, state = event.window.get_pointer()
else:
x = event.x
y = event.y
self.coordwin.set_text("(%d, %d)" % (x, y))
state = event.state
# Lets you draw pixels by dragging, not the effect we want.
#if state & gtk.gdk.BUTTON1_MASK and self.pixmap != None:
# self.button_left_click(widget, x, y)
return True
#
# These two functions implement a civilized quit confirmation that
# prompts you if you have unnsaved changes
#
def conditional_quit(self, w):
if self.journey.has_unsaved_changes():
self.quit_check = gtk.Dialog(title="Really quit?",
parent=None,
flags=gtk.DIALOG_MODAL,
buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT,
gtk.STOCK_OK, gtk.RESPONSE_ACCEPT))
label = gtk.Label("Track has unsaved changes. OK to quit?")
self.quit_check.vbox.pack_start(label)
label.show()
self.quit_check.connect("response", self.conditional_quit_handler)
self.quit_check.run()
else:
sys.exit(0)
def conditional_quit_handler(self, widget, id):
if id == gtk.RESPONSE_ACCEPT:
sys.exit(0)
elif id == gtk.RESPONSE_REJECT:
self.quit_check.destroy()
def help_handler(self, w):
"Display help."
w = gtk.MessageDialog(type=gtk.MESSAGE_INFO,
flags=gtk.DIALOG_DESTROY_WITH_PARENT,
buttons=gtk.BUTTONS_OK)
w.set_markup(gui_help)
w.run()
w.destroy()
def save_handler(self, w):
"Save track data,"
if not self.journey.has_unsaved_changes():
w = gtk.MessageDialog(type=gtk.MESSAGE_INFO,
flags=gtk.DIALOG_DESTROY_WITH_PARENT,
buttons=gtk.BUTTONS_OK)
w.set_markup("You have no unsaved changes.")
w.run()
w.destroy()
else:
w = ModalFileSelector(default=self.last_read, legend="Save track to file")
if not w.path.endswith(".trk") and not w.path.endswith(".cfg"):
raise IOException("File must have a .trk or .cfg extension.", w.path)
if w.path != self.last_read and os.path.exists(w.path):
self.save_check = gtk.Dialog(title="Really overwrite?",
parent=None,
flags=gtk.DIALOG_MODAL,
buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT,
gtk.STOCK_OK, gtk.RESPONSE_ACCEPT))
label = gtk.Label("Overwrite existing data in %s?" % w.path)
self.save_check.vbox.pack_start(label)
label.show()
self.save_check.connect("response",
self.conditional_save_handler)
self.save_check.run()
# After conditional_save handler fires
if not self.save_confirm:
return
self.log("Writing track data to %s" % w.path)
try:
fp = open(w.path, "w")
except IOError:
raise IOException("Cannot write file.", w.path)
self.journey.write(fp)
def conditional_save_handler(self, widget, id):
self.save_confirm = (id == gtk.RESPONSE_ACCEPT)
self.save_check.destroy()
def log(self, msg):
"Notify user of error and die."
if self.verbose:
print >>sys.stderr, "trackplacer:", msg
def fatal_error(self, msg):
"Notify user of error and die."
w = gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK)
w.set_markup(msg)
w.run()
sys.exit(1)
if __name__ == "__main__":
(options, arguments) = getopt.getopt(sys.argv[1:], "d:hv?",
['directory=', 'help', 'verbose'])
verbose = False
top = None
for (opt, val) in options:
if opt in ('-d', '--directory'):
top = val
elif opt in ('-?', '-h', '--help'):
print __doc__
sys.exit(0)
elif opt in ('-v', '--verbose'):
verbose=True
if top:
os.chdir(top)
else:
wesnoth.wmltools.pop_to_top("trackplacer")
if arguments:
try:
TrackEditor(path=arguments[0], verbose=verbose)
except IOException, e:
sys.stderr.write(e.message + "\n")
else:
while True:
try:
selector = ModalFileSelector(default=default_map,
legend="Track or map file to read")
if not selector.path:
break
TrackEditor(selector.path, verbose=verbose)
except IOException, e:
w = gtk.MessageDialog(type=gtk.MESSAGE_ERROR,
flags=gtk.DIALOG_DESTROY_WITH_PARENT,
buttons=gtk.BUTTONS_OK)
if e.lineno:
errloc = '"%s", line %d:' % (e.path, e.lineno)
# Emacs friendliness
sys.stderr.write(errloc + " " + e.message + "\n")
else:
errloc = e.path + ":"
w.set_markup(errloc + "\n\n" + e.message)
w.run()
w.destroy()