A* path finding using a game’s wiki

Navigate the result here!

I recently replayed Metroid Prime 2 (for GameCube) and got lost. A lot. This game features many interconnecting rooms, most of which exist in a dark and a light dimension with one-way or two-way portals. It took ages to figure out what item to collect when and where, so I did what anyone would do and coded a system to find the optimal path to finish the game!

The game works with rooms, basically every room has a bunch of doors, usually 2 complex rooms are connected by tunnels, this allows the game to close doors and stream the world very late (as we don’t have any view of unloaded areas to worry about). To start out I scraped this site (while saving every page loaded to disk as to not get banned for doing too many requests). Every link in that list goes to a page that describes what other places we can go from this place, as well as what upgrades can be found in this room. The connecting rooms are also formulated like “Room X (through red door)”, from which I can derive that a red door requires the missile launcher upgrade. So I can scrape limitations as well!

This allowed me to build a massive structure. Instead of making serialization work I just did code generation into a set of global dictionaries. That looks a bit like this:

# structure.py
class Or(object):
    def __init__(self, *args):
        self.options = args

    def __str__(self):
        return 'Or(\'%s\')' % '\', \''.join(self.options)

    def __eq__(self, other):
        for opt in self.options:
            if opt == other:
                return True
        return False


class Item(object):
    def __init__(self, name):
        self.name = name
        self.requirements = []


class Transition(object):
    def __init__(self, sourceRoom, targetRoom):
        self.sourceRoom, self.targetRoom, self.requirements = sourceRoom, targetRoom, []

    def deregister(self):
        self.sourceRoom.transitions.remove(self)


areas = ['Temple Grounds', 'Sky Temple Grounds', 'Great Temple', 'Sky Temple', 'Agon Wastes', 'Dark Agon Wastes', 'Torvus Bog', 'Dark Torvus Bog', 'Sanctuary Fortress', 'Ing Hive']


class Room(object):
    def __init__(self, name):
        self.name = name
        self.transitions = []
        self.items = []
        self.area = None

    def displayData(self):
        print '%s (%s)' % (self.name, self.area)
        print '\tConnects to:'
        for transition in self.transitions:
            print transition.targetRoom.name, transition.requirements
        print '\tItems:'
        for item in self.items:
            print item.name
from structure import *

# list items in the game
items = {
    'beam ammo expansion1': Item('Beam Ammo Expansion'),
    'beam ammo expansion2': Item('Beam Ammo Expansion'),
    'beam ammo expansion3': Item('Beam Ammo Expansion'),
    'beam ammo expansion4': Item('Beam Ammo Expansion'),
    'boost ball': Item('Boost Ball'),
# ... etcetera
}
# list rooms in the game
rooms = {
    ('transit station', 'Agon Wastes'): Room('Transit Station'),
    ('mining plaza', 'Agon Wastes'): Room('Mining Plaza'),
# ... etcetera
}

# denote room worlds and contents
rooms[('transit station', 'Sanctuary Fortress')].area = 'Sanctuary Fortress'
rooms[('transit station', 'Sanctuary Fortress')].items.append(items['power bomb expansion6'])
# ... etcetera

# connect rooms in the game
transitions = {
    (('transit station', 'Agon Wastes'), ('mining plaza', 'Agon Wastes')): Transition(rooms[('transit station', 'Agon Wastes')], rooms[('mining plaza', 'Agon Wastes')]),
# ... etcetera
}
# register the connections
rooms[('transit station', 'Agon Wastes')].transitions.append(transitions[(('transit station', 'Agon Wastes'), ('mining plaza', 'Agon Wastes'))])
# ... etcetera
# add inventory requirements to the connections
transitions[(('sacred path', 'Temple Grounds'), ('profane path', 'Sky Temple Grounds'))].requirements.append('Dark Beam')
# ... etcetera

After executing that code I would have every room in the game in memory, where every room has links to the objects they’re connected to, including constraints such as upgrade requirements to open the door between those rooms!

Now with all this information, I can start writing a navigation unit in the game world. My unit has a certain inventory of upgrades and a current location and it can figure out where it can and can’t go at this point. So I wrote an A* path finder but for every connection I only consider evaluating it if the requirements of going through that door are met by the inventory.

Putting the unit at the start of the game and telling it to grab the first upgrade works instantly like this! A bit of a harder extension was adding the possibility to realize the requirements for a transition and then finding the path to that requirement first. This also slowed the algorithm down by a lot, but it does allow me to say “start here, go to final boss” and the navigator will figure out what to do! I realized there’s 100! (factorial) options to gather the upgrades so stuck with the manual order input for now…

I wanted to add the desire to obtain all items in the game, to complete it with 100% of the upgrades, but didn’t get around to it.

Next up was visualization! I soon realized that to highlight rooms on the map the most time efficient way was to just bite the bullet for an evening and trace them…
I wrote this little tool where I can navigate the different maps and draw the polygons for each room. That then gets saved as JSON and got used by the visualizer.

And here’s the code!

import json
import os
from qt import *
from scrapeoutput import *


class PolygonView(QWidget):
    def __init__(self, owner):
        super(PolygonView, self).__init__()
        self._owner = owner
        self.roomPolygons = {}
        self.setFocusPolicy(Qt.StrongFocus)
        self._panStart = None
        self.background = None
        self._panX = 0
        self._panY = 0

    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self._panStart = self._panX, self._panY, event.x(), event.y()
            return

    def mouseReleaseEvent(self, event):
        if self._panStart is not None:
            self._panStart = None
            return

    def mouseMoveEvent(self, event):
        if self._panStart is not None:
            self._panX = self._panStart[0] + (event.x() - self._panStart[2])
            self._panY = self._panStart[1] + (event.y() - self._panStart[3])
            self.repaint()
            return

    def paintBackground(self, painter):
        if self.background:
            painter.setOpacity(0.2)
            painter.drawImage(0, 0, self.background)
            painter.setOpacity(1.0)

    def findActivePolygon(self):
        key = self._owner.activeKey()
        if key is None:
            return
        points = self.roomPolygons.get(key, [])
        if not points:
            return None
        return points

    def drawPolygon(self, painter, points):
        solidWhite = QPen(Qt.white, 1, Qt.SolidLine)
        dottedWhite = QPen(Qt.white, 1, Qt.DotLine)
        for i in xrange(len(points)):
            painter.setPen(solidWhite)
            painter.setBrush(Qt.NoBrush)
            x, y = points[i].x(), points[i].y()
            if i == 0:
                painter.setBrush(Qt.white)
            painter.drawRect(QRectF(x - 2.5, y - 2.5, 5.0, 5.0))
            if i == len(points) - 1:
                painter.setPen(dottedWhite)
            painter.drawLine(points[i], points[(i + 1) % len(points)])

    def paint(self, painter):
        self.paintBackground(painter)
        painter.setRenderHint(QPainter.Antialiasing)
        points = self.findActivePolygon()
        if not points:
            return
        self.drawPolygon(painter, points)

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.fillRect(QRect(0, 0, self.width(), self.height()), Qt.black)

        painter.translate(self._panX, self._panY)
        self.paint(painter)


def quadZoom(zoom, steps):
    # apply steps as multiplicative zoom to get a quadratic curve
    if steps < 0:
        return zoom * pow(1.000794567, steps)
    else:
        return zoom * pow(0.99912239, -steps)


class PolygonZoom(PolygonView):
    def __init__(self, owner):
        super(PolygonZoom, self).__init__(owner)
        self.zoom = 1.0
        self.state = 0

    def wheelEvent(self, event):
        oldUnits = (event.x() - self._panX) / self.zoom, (event.y() - self._panY) / self.zoom
        self.zoom = quadZoom(self.zoom, event.delta())
        newUnits = (event.x() - self._panX) / self.zoom, (event.y() - self._panY) / self.zoom
        deltaUnits = newUnits[0] - oldUnits[0], newUnits[1] - oldUnits[1]
        self._panX += deltaUnits[0] * self.zoom
        self._panY += deltaUnits[1] * self.zoom
        self.repaint()

    def drawPolygon(self, painter, points):
        if self.state == 0:
            painter.setPen(Qt.white)
        else:
            painter.setPen(QPen(QBrush(QColor(255, 180, 40)), 3.0))
        for i in xrange(len(points)):
            painter.drawLine(points[i], points[(i + 1) % len(points)])

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.fillRect(QRect(0, 0, self.width(), self.height()), Qt.black)

        painter.translate(self._panX, self._panY)
        painter.scale(self.zoom, self.zoom)
        self.paint(painter)


class PolygonEdit(PolygonView):
    def __init__(self, owner):
        super(PolygonEdit, self).__init__(owner)
        self.__dragStart = None

    def __indexUnderMouse(self, polygon, pos):
        tolerance = 8
        for index, point in enumerate(polygon):
            delta = point - pos
            if abs(delta.x()) + abs(delta.y()) < tolerance:
                return index
        return None

    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self._panStart = self._panX, self._panY, event.x(), event.y()
            return

        if event.button() != Qt.LeftButton:
            return

        key = self._owner.activeKey()
        if key is None:
            return

        polygon = self.roomPolygons.get(key, None)
        if polygon is None:
            return None

        localPos = event.pos() - QPoint(self._panX, self._panY)
        index = self.__indexUnderMouse(polygon, localPos)
        if index is None:
            return

        self.__dragStart = localPos, polygon, index, QPoint(polygon[index])

    def mouseReleaseEvent(self, event):
        if self._panStart is not None:
            self._panStart = None
            return

        if self.__dragStart is None:
            if event.modifiers() == Qt.ControlModifier:
                key = self._owner.activeKey()
                if key is None:
                    return
                polygon = self.roomPolygons.get(key, [])
                localPos = event.pos() - QPoint(self._panX, self._panY)
                polygon.append(localPos)
                self.roomPolygons[key] = polygon
                self.repaint()
                return

            if event.button() == Qt.RightButton:
                key = self._owner.activeKey()
                if key is None:
                    return
                polygon = self.roomPolygons.get(key, None)
                if polygon is None:
                    return
                localPos = event.pos() - QPoint(self._panX, self._panY)
                index = self.__indexUnderMouse(polygon, localPos)
                if index is None:
                    return
                polygon.pop(index)
                self.repaint()
                return

        self.__dragStart = None

    def mouseMoveEvent(self, event):
        if self._panStart is not None:
            self._panX = self._panStart[0] + (event.x() - self._panStart[2])
            self._panY = self._panStart[1] + (event.y() - self._panStart[3])
            self.repaint()
            return
        if self.__dragStart is None:
            return
        localPos = event.pos() - QPoint(self._panX, self._panY)
        delta = localPos - self.__dragStart[0]
        self.__dragStart[1][self.__dragStart[2]] = self.__dragStart[3] + delta
        self.repaint()

    def paint(self, painter):
        self.paintBackground(painter)
        painter.setRenderHint(QPainter.Antialiasing)
        activeKey = self._owner.activeKey()
        if not activeKey:
            return

        painter.setOpacity(0.2)
        for key in self.roomPolygons:
            if key[1] == activeKey[1] and key[0] != activeKey[0]:
                self.drawPolygon(painter, self.roomPolygons[key])
        painter.setOpacity(1.0)

        points = self.findActivePolygon()
        if not points:
            return
        self.drawPolygon(painter, points)


class App(QMainWindowState):
    def __init__(self):
        super(App, self).__init__(QSettings('ImageEditor'))

        self.__worldList = QListView()
        self.__roomList = QListView()
        self.__view = PolygonEdit(self)
        self.__worldList.setModel(QStandardItemModel())
        self.__roomList.setModel(QStandardItemModel())
        self._addDockWidget(self.__worldList, 'Worlds')
        self._addDockWidget(self.__roomList, 'Rooms')
        self._addDockWidget(self.__view, 'View')

        self.__activeRoom = None
        self.__activeWorld = None
        self.__worldRooms = {}
        worldList = self.__worldList.model()
        for key in rooms:
            world = key[1]
            if world not in self.__worldRooms:
                worldList.appendRow(QStandardItem(world))
                self.__worldRooms[world] = []
            self.__worldRooms[world].append(key[0])

        self.__worldList.selectionModel().selectionChanged.connect(self.onWorldSelected)
        self.__roomList.selectionModel().selectionChanged.connect(self.onRoomSelected)

        self.__menuBar = QMenuBar()
        self.setMenuBar(self.__menuBar)
        self.__fileMenu = self.__menuBar.addMenu('File')
        self.__fileMenu.addAction('Save').triggered.connect(self.__onSave)
        self.__fileMenu.addAction('Load').triggered.connect(self.__onLoad)
        self.__fileMenu.addAction('Export').triggered.connect(self.__onExport)

        if os.path.exists('autosave.json'):
            self.load('autosave.json')

    def closeEvent(self, event):
        self.save('autosave.json')

    def __onSave(self):
        filePath = QFileDialog.getSaveFileName(self, 'Save', os.path.dirname(os.path.normpath(__file__)), '*.json')
        if filePath is not None:
            self.save(filePath)

    def __onLoad(self):
        filePath = QFileDialog.getOpenFileName(self, 'Load', os.path.dirname(os.path.normpath(__file__)), '*.json')
        if filePath is not None:
            self.load(filePath)

    def __onExport(self):
        filePath = QFileDialog.getExistingDirectory(self, 'Export', os.path.dirname(os.path.normpath(__file__)))
        if filePath is not None:
            self.export(filePath)

    def export(self, filePath):
        for key, value in self.__view.roomPolygons.iteritems():
            minX = min(value, key=lambda p: p.x()).x()
            minY = min(value, key=lambda p: p.y()).y()
            maxX = max(value, key=lambda p: p.x()).x()
            maxY = max(value, key=lambda p: p.y()).y()
            dest = os.path.join(filePath, '__'.join((key[0], key[1], str(minX), str(minY))) + '.bmp')
            image = QImage(maxX - minX + 1, maxY - minY + 1, QImage.Format_ARGB32)
            image.fill(Qt.black)
            for i, point in enumerate(value):
                image.setPixel(point.x() - minX, point.y() - minY, qRgb(i, 0, 0))
            image.save(dest)

    def save(self, filePath):
        with open(filePath, 'w') as fh:
            result = []
            for key, value in self.__view.roomPolygons.iteritems():
                format = {}
                format['key'] = key
                format['value'] = [(point.x(), point.y()) for point in value]
                result.append(format)
            json.dump(result, fh)

    def load(self, filePath):
        with open(filePath) as fh:
            self.__view.roomPolygons.clear()
            format = json.load(fh)
            for item in format:
                key, value = item['key'], item['value']
                self.__view.roomPolygons[(str(key[0]), str(key[1]))] = [QPoint(*point) for point in value]

    def activeKey(self):
        key = self.__activeRoom, self.__activeWorld
        if None in key:
            return None
        return key

    def onWorldSelected(self, *args):
        idx = self.__worldList.selectionModel().currentIndex()
        roomList = self.__roomList.model()
        roomList.clear()
        if not idx.isValid():
            self.__activeWorld = None
            self.__view.background = None
            return
        item = self.__worldList.model().itemFromIndex(idx)
        self.__activeWorld = str(item.text())
        self.__view.background = QImage(self.__activeWorld + '.jpg')
        for room in self.__worldRooms[self.__activeWorld]:
            inst = QStandardItem(room)
            if (room.lower(), self.__activeWorld) in self.__view.roomPolygons:
                inst.setBackground(Qt.lightGray)
            roomList.appendRow(inst)

        self.onRoomSelected()

    def onRoomSelected(self, *args):
        idx = self.__roomList.selectionModel().currentIndex()
        if not idx.isValid():
            self.__activeRoom = None
            self.__view.repaint()
            return
        item = self.__roomList.model().itemFromIndex(idx)
        self.__activeRoom = str(item.text())
        self.__view.repaint()


if __name__ == '__main__':
    a = QApplication([])
    w = App()
    w.show()
    a.exec_()

Then for navigation I wrote a basic A* pathfinder. This is the most basic introduction I could find, I think I learned from this same site years ago!

import json
import itertools
from structure import *
from scrapeoutput import *


_cache = {}
def findItem(target):
    """ given an item name find what room it lives in (and cache it for speedier queries in the future). """
    room = _cache.get(target, None)
    if room:
        return room
    for key, room in rooms.iteritems():
        if target in room.items:
            _cache[target] = room
            return room


def canTransition(transition, inventory):
    """
    check if a path is blocked
    :type transition: Transition
    :type inventory: [str]
    :rtype: bool
    """
    for requirement in transition.requirements:
        if requirement not in inventory:
            return False
    return True


class AStarNode(object):
    """ simply points to a room and knows how to get there through the hierarchy system """
    def __init__(self, data, parent=None):
        self.data = data
        self.parent = parent

    def cost(self):
        """ assume navigating through a room always costs "1", so going through as little doors as possible is assumed to be the shortest path """
        if self.parent is not None:
            return 1 + self.parent.cost()
        return 1

    def __repr__(self):
        if self.parent is not None:
            return '%s (from %s)' % (self.data.name, self.parent.data.name)
        return self.data.name


def growAdjacentNodes(node, inventory, open, closed):
    """
    Update the open list:
    Get adjacent tiles from node.
    If in closed do not add.
    If in open update best score.
    Else add to open.

    :type node: AStarNode
    :type inventory: [str]
    :type open: [AStarNode]
    :type closed: [AStarNode]
    :rtype: [AStarNode]
    """
    for transition in node.data.transitions:
        if not canTransition(transition, inventory):
            continue

        isInClosedList = False
        for entry in closed:
            if entry.data == transition.targetRoom:
                isInClosedList = True
                break
        if isInClosedList:
            continue

        newNode = AStarNode(transition.targetRoom, node)
        cost = newNode.cost()
        for i, entry in enumerate(open):
            if entry.data == transition.targetRoom:
                if cost < entry.cost():
                    open[i] = newNode
                break
        else:
            open.append(newNode)


class AstarException(Exception): pass


def aStar(startRoom, destinationRoom, inventory):
    """ basic path finder """
    closed = [AStarNode(startRoom)]
    open = []
    growAdjacentNodes(closed[0], inventory, open, closed)
    destinationNode = None
    for i in xrange(10000):  # failsafe
        for entry in open:
            if entry.data == destinationRoom:
                destinationNode = entry
                break
        if destinationNode:
            break
        if not open:
            raise AstarException('Out of options searching %s. Closed list: \n%s' % (destinationRoom.name, '\n'.join(str(i) for i in closed)))
        best = min(open, key=lambda x: x.cost())
        open.remove(best)
        closed.append(best)
        growAdjacentNodes(best, inventory, open, closed)
    iter = destinationNode
    path = []
    while iter:
        path.insert(0, (iter.data.name, iter.data.area))
        iter = iter.parent
    return path


def shortestPath(startRoom, itemKey, ioInventory):
    """ utility to kick off the path finder and compile the results """
    target = items[itemKey]
    for requirement in target.requirements:
        if requirement not in ioInventory:
            raise AstarException()
    destinationRoom = findItem(target)
    if startRoom == destinationRoom:
        ioInventory.append(target.name)
        return ['Aquired: %s' % target.name], destinationRoom
    path = aStar(startRoom, destinationRoom, ioInventory)
    if not path:
        raise RuntimeError()
    ioInventory.append(target.name)
    return path[1:] + ['Aquired: %s' % target.name], destinationRoom


def shortestMultiPath(startRoom, orderlessItems, finalItem, ioInventory):
    """ utility to try out all ways to get the orderlessItems and returns the most optimal version
    finalItem is the next item to get, it is included because the most optimal route should also be close to the next destination to be truly optimal """
    results = []
    for option in itertools.permutations(orderlessItems):
        inv = ioInventory[:]
        totalPath = []
        cursor = startRoom
        for itemKey in option + (finalItem,):
            path, cursor = shortestPath(cursor, itemKey, inv)
            totalPath += path
        results.append((totalPath, cursor, inv))
    # get result with shortest path
    result = min(results, key=lambda r: len(r[0]))
    ioInventory += result[2][len(ioInventory):]
    return result[0], result[1]

With this we can navigate from item to item and record the whole play through. After doing this some amendmends were necessary to fix things missed by the wiki scrape, such as activating a switch in room A to move aside something in room B. I solved that by adding a "switch" item to the right room and inserting it into the ordered item list that the navigator will walk along. After I had the pathfinder working and showing the resulting path on a map with highlighting, I went on and exported that all to a webpage! I just added that inline in the startup code. This whole thing does the navigation, generates a webpage and then shows a Qt app in a surprisingly small amount of time.

Here is that visualizer code:

from image_editor import PolygonZoom
from qt import *


class App(QMainWindowState):
    def __init__(self):
        super(App, self).__init__(QSettings('Navigator'))
        self.view = PolygonZoom(self)
        self.timeline = QListView()
        model = QStandardItemModel()
        self.timeline.setModel(model)
        self.setCentralWidget(self.view)
        self._addDockWidget(self.timeline, 'Timeline', Qt.LeftDockWidgetArea)
        self.timeline.selectionModel().selectionChanged.connect(self.__onUpdate)
        self.__key = None

        with open('autosave.json') as fh:
            self.view.roomPolygons.clear()
            format = json.load(fh)
            for item in format:
                key, value = item['key'], item['value']
                self.view.roomPolygons[(str(key[0]), str(key[1]))] = [QPoint(*point) for point in value]

        # record playthrough
        inventory = []
        itemOrder = ('missile launcher', 'violet translator', 'morph ball bomb', 'amber translator', 'space jump boots', 'dark beam', 'light beam',
                     ('dark agon temple key 1', 'dark agon temple key 2', 'dark agon temple key 3', 'dark suit'), 'agon energy', 'agon energy delivery',
                     'super missile', 'emerald translator', 'boost ball', 'dark torvus temple key 1', 'seeker launcher',
                     'catacombs lock', 'gathering lock',
                     'gravity boost', 'grapple beam',
                     ('dark torvus temple key 2', 'dark torvus temple key 3', 'dark visor'), 'torvus energy', 'torvus energy delivery',
                     'spider ball', 'power bomb', 'echo visor', 'screw attack',
                     ('ing hive temple key 1', 'ing hive temple key 2', 'ing hive temple key 3', 'annihilator beam'), 'sanctuary energy', 'sanctuary energy delivery',
                     'light suit',
                     'sky temple key 1', 'sky temple key 2', 'sky temple key 3', 'sky temple key 4', 'sky temple key 5', 'sky temple key 6', 'sky temple key 7', 'sky temple key 8', 'sky temple key 9',
                     'temple energy',
                     'temple energy delivery',

                     'missile expansion17', 'beam ammo expansion3', 'power bomb expansion5', 'darkburst', 'missile expansion12'
                     )
        startRoom = rooms[('landing site', 'Temple Grounds')]

        inst = QStandardItem('Landing Site')
        inst.setData('Temple Grounds')
        model.appendRow(inst)

        for itemKey in itemOrder:
            try:
                if isinstance(itemKey, tuple):
                    path, startRoom = shortestMultiPath(startRoom, itemKey[:-1], itemKey[-1], inventory)
                else:
                    path, startRoom = shortestPath(startRoom, itemKey, inventory)
            except ValueError:
                print 'Error finding %s' % str(itemKey)
                raise
            if not path:
                raise RuntimeError()
            for room_area in path:
                if isinstance(room_area, tuple):
                    room, area = room_area
                else:
                    room, area = room_area, None
                inst = QStandardItem(room)
                if area:
                    inst.setData(area)
                model.appendRow(inst)

        worldPolygons = {}
        with open('index.html', 'w') as fh:
            fh.write('')
            fh.write('')
            fh.write('')
            fh.write('')
            fh.write('
') for i in xrange(model.rowCount()): data = str(model.item(i).data()) room = str(model.item(i).text()) if not room.lower().startswith('aquired'): worldPolygons[data] = worldPolygons.get(data, {}) try: worldPolygons[data][room] = self.view.roomPolygons[(room.lower(), data)] except: worldPolygons[data][room] = [] data = '' if not data else 'data="%s"' % data fh.write('
%s
' % (data, room)) fh.write('
') with open('polygons.js', 'w') as fh: fh.write('polygons = {') for world in worldPolygons: fh.write('"%s": {' % world) for room in worldPolygons[world]: if worldPolygons[world][room]: poly = '%s' % (', '.join(['[%s, %s]' % (pt.x(), pt.y()) for pt in worldPolygons[world][room]])) fh.write('"%s": [%s],' % (room, poly)) fh.write('},') fh.write('};') def activeKey(self): return self.__key def __onUpdate(self, *args): self.view.state = 0 idx = self.timeline.selectionModel().currentIndex() if idx.isValid(): item = self.timeline.model().itemFromIndex(idx) label = str(item.text().lower()) if label.startswith('aquired: '): item = self.timeline.model().item(idx.row() - 1) label = str(item.text().lower()) self.view.state = 1 world = str(item.data()) self.__key = label, world self.view.background = QImage(world + '.jpg') else: self.__key = None self.view.background = None self.view.repaint() if __name__ == '__main__': a = QApplication([]) w = App() w.show() a.exec_()

This is definitely one of the sillier things to do in your spare time 🙂
Navigate the result here!

Leave a Reply

Your email address will not be published. Required fields are marked *