inital commit
authorNiki Roo <niki@nikiroo.be>
Sun, 23 Mar 2025 17:29:51 +0000 (18:29 +0100)
committerNiki Roo <niki@nikiroo.be>
Sun, 23 Mar 2025 17:29:51 +0000 (18:29 +0100)
Gamiki/Builder.py [new file with mode: 0644]
Gamiki/Game.py [new file with mode: 0644]
Gamiki/Library.py [new file with mode: 0644]
Gamiki/Support/Support.py [new file with mode: 0644]
Gamiki/Support/SupportDos.py [new file with mode: 0644]
Gamiki/Support/SupportGog.py [new file with mode: 0644]
Gamiki/Support/SupportWin31.py [new file with mode: 0644]
Gamiki/Support/__init__.py [new file with mode: 0644]
Gamiki/__init__.py [new file with mode: 0644]
Main.py [new file with mode: 0755]
requirements.txt [new file with mode: 0644]

diff --git a/Gamiki/Builder.py b/Gamiki/Builder.py
new file mode 100644 (file)
index 0000000..9644bb6
--- /dev/null
@@ -0,0 +1,99 @@
+from os      import listdir
+from os.path import isfile, isdir, join, expanduser, realpath, exists
+
+from .        import Library, Game
+from .Support import Support, SupportDos, SupportWin31, SupportGog
+
+class Builder:
+    """Manage all the resources needed to run games."""
+    def __init__(self):
+        self.libraries : list[Library] = []
+        self.games     : list[Game]    = []
+        
+        supports = []
+        supports.append(SupportGog())
+        supports.append(SupportWin31()) # prio over DOS
+        supports.append(SupportDos())
+        
+        targets = []
+        if (exists(expanduser("~/.games.cfg"))):
+            with open(file, encoding="utf-8") as data:
+                ln = data.strip()
+                if (not ln.startswith("#")):
+                    targets.append(ln)
+        else:
+            targets.append(expanduser("~/Games"))
+        
+        for target in targets:
+            self.load_libs(supports, target)
+        
+        for lib in self.libraries:
+            for game in lib:
+                self.games.append(game)
+    
+    def load_libs(self, supports: list[Support], dir: str):
+        for path in listdir(dir):
+            fullpath = join(dir, path)
+            if (not isdir(realpath(fullpath))):
+                continue
+            
+            try:
+                self.libraries.append(Library(fullpath, supports))
+            except FileNotFoundError:
+                pass
+        
+    def list(self):
+        i = 1
+        for game in self.games:
+            print("{0:6d}".format(i), "", game.name)
+            i += 1
+    
+    def list_tag(self):
+        i = 1
+        for game in self.games:
+            print("{0:6d}".format(i), "", game.src + ":", game.name)
+            i += 1
+    
+    def list_tags(self):
+        i = 1
+        for game in self.games:
+            print(
+                    "{0:6d}".format(i), "", game.name, 
+                    "(" + ";".join(game.tags)  +")")
+            i += 1
+    
+    def find(self, key: str) -> Game:
+        game: Game = None
+        
+        try:
+            i = int(key)
+            game = self.games[i-1]
+        except ValueError:
+            pass
+        except IndexError as orig:
+            raise ValueError("Game index does not exist: " + key)
+        
+        # TODO: allow regex
+        
+        if game == None: 
+            for g in self.games:
+                if (g.code == key):
+                    if (game != None):
+                        raise ValueError(
+                                "Multiple codes match, please specify: " + key
+                        )
+                    game = g
+                    break
+        
+        if game == None: 
+            for g in self.games:
+                if (g.name == key):
+                    if (game != None):
+                        raise ValueError(
+                                "Multiple names match, please specify: " + key
+                        )
+                    game = g
+                    break
+        
+        return game;
+        
diff --git a/Gamiki/Game.py b/Gamiki/Game.py
new file mode 100644 (file)
index 0000000..babe18d
--- /dev/null
@@ -0,0 +1,49 @@
+from os.path import basename as basename
+
+class Game(dict[str, str]):
+    """Game object generated from a directory."""
+    def __init__(self, library, dir: str):
+        # TODO: doc: dir = full path
+        self.dir     = dir.removesuffix("/")
+        self.name    = basename(self.dir)
+        self.code    = basename(self.dir)
+        self.src     = ""
+        self.tags    = []
+        self.desc    = ""
+        self.support = None
+        self.library = library
+        
+        self._read_info(self.dir + "/gameinfo.txt")
+            
+        if ("Name" in self):
+            self.name = self["Name"]
+        if ("Code" in self):
+            self.code = self["Code"]
+        if ("Tags" in self):
+            self.tags = [ tag.strip() for tag in self["Tags"].split(";") ]
+            if (self.tags):
+                self.src = self.tags[0]
+        if ("Desc" in self):
+            self.desc = self["Desc"].replace("\\n", "\n").strip()
+    
+    def _read_info(self, file: str):
+        try:
+            with open(file, encoding="utf-8") as data:
+                for ln in data:
+                    ln = ln.strip()
+                    tab = ln.split("=", maxsplit=1)
+                    if (len(ln) and ln[0] != '#' and len(tab) == 2):
+                        self[tab[0]] = tab[1]
+        except FileNotFoundError:
+            pass
+        
+
+    def set_support(self, support):
+        self.support = support
+    
+    def start(self, params: list[str] = []):
+        if (self.support == None):
+            raise RuntimeError("Unsupported game was called: " + game.name)
+        
+        self.support.start(self, params)
+
diff --git a/Gamiki/Library.py b/Gamiki/Library.py
new file mode 100644 (file)
index 0000000..47b1228
--- /dev/null
@@ -0,0 +1,34 @@
+from os      import listdir
+from os.path import isdir, join, realpath, exists
+
+from .Support import Support
+from .Game    import Game
+
+class Library(list[Game]):
+    """Manage a library (a folder) of Games."""
+    
+    def __init__(self, dir: str, supports: list[Support]):
+        self.name             = '' # TODO
+        self.dir              = dir
+        self.supports         = supports
+        self.preferredSupport = None
+        # TODO: dir = fullpath
+        
+        config = join(self.dir, "games.cfg")
+        if (not exists(config)):
+            raise FileNotFoundError("Library is not configured (games.cfg)")
+        
+        # TODO: get name from config?
+
+        for path in listdir(self.dir):
+            fullpath = join(self.dir, path)
+            if (not isdir(realpath(fullpath))): 
+                continue
+            game = Game(self, fullpath)
+            for support in self.supports:
+                if (support.supports(game)):
+                    game.set_support(support)
+                    break;
+            if (game.support != None):
+                self.append(game)
+            
diff --git a/Gamiki/Support/Support.py b/Gamiki/Support/Support.py
new file mode 100644 (file)
index 0000000..f22ecf6
--- /dev/null
@@ -0,0 +1,29 @@
+from subprocess import run
+from shutil     import which
+#from typing     import Any, Self
+
+from ..Game import Game
+
+class Support:    
+    programs: dict[str, str] = None
+    
+    """Can detect and start games."""
+    def __init__(self):
+        pass
+    def supports(self, game: Game) -> bool:
+        return False
+    
+    def start(self, game: Game, params: list[str] = []):
+        if (not self.supports(game)):
+            raise RuntimeError("Unsupported game was called: " + game.name)
+    
+    def program(name: str) -> str:
+        if (Support.programs == None):
+            Support.programs = {}
+        
+        if (name not in Support.programs):
+            Support.programs[name] = which(name)
+        
+        return Support.programs[name]
+
diff --git a/Gamiki/Support/SupportDos.py b/Gamiki/Support/SupportDos.py
new file mode 100644 (file)
index 0000000..e89954a
--- /dev/null
@@ -0,0 +1,43 @@
+from os.path    import exists, realpath
+from subprocess import run
+from shutil     import which
+
+from .  import Support
+from .. import Game
+
+class SupportDos(Support):
+    """Supports DOS games via DosBox."""
+    def __init__(self):
+        pass
+            
+    def supports(self, game: Game):
+        return (exists(game.dir + "/start.conf"))
+    
+    def start(self, game: Game, params: list[str] = []):
+        dir = realpath(game.dir)
+        link_dir = realpath(self.get_link_dir(game))
+        
+        if (Support.program("app.sh") != None):
+            cmd = [ 
+                    Support.program("app.sh"),
+                    "--wait", "dosbox", "--link", link_dir 
+            ]
+        elif (Support.program("dosbox") != None):
+            cmd = [ Support.program("dosbox") ]
+        else:
+            raise RuntimeError("DosBox not found")
+        
+        if (exists(dir + "/base.conf")):
+            cmd.append("-conf")
+            cmd.append(dir + "/base.conf")
+        cmd.append("-conf")
+        cmd.append(dir + "/start.conf")
+        
+        print("Running", game.name)
+        rep = run(cmd, cwd=dir)
+        if (rep.returncode != 0):
+            print("\nError when running", game.name, ": RC", rep.returncode)
+
+    def get_link_dir(self, game: Game) -> str:
+        return (game.dir)
+
diff --git a/Gamiki/Support/SupportGog.py b/Gamiki/Support/SupportGog.py
new file mode 100644 (file)
index 0000000..cacf212
--- /dev/null
@@ -0,0 +1,61 @@
+from random     import random
+from math       import floor
+from os.path    import exists, realpath
+from os         import environ
+from pathlib    import Path
+from subprocess import run
+from shutil     import which
+
+from .  import Support
+from .. import Game
+
+class SupportGog(Support):
+    """Supports GoG games via the 'games' docker or natively."""
+    def __init__(self):
+        pass
+            
+    def supports(self, game: Game):
+        return (exists(game.dir + "/gameinfo"))
+    
+    def start(self, game: Game, params: list[str] = []):
+        dir = realpath(game.dir)
+        
+        if (not exists(dir + "/start.sh")):
+            raise RuntimeError("GoG game without a start.sh script")
+        
+        env = environ.copy()
+
+        ffile = None
+        if (Support.program("launch.sh") != None):
+            ffile = Path(
+                    "/tmp/shared/.game-" 
+                    + "{0:6d}".format(floor(random() * 1000000))
+                    + ".sh"
+            )
+            with open(ffile, 'w', encoding="utf-8") as data:
+                data.write("#!/bin/sh\n")
+                data.write("cd '" + dir + "'\n")
+                data.write("./start.sh\n")
+            ffile.chmod(0o777)
+            env["OPTS"] = "--entrypoint=" + ffile.as_posix()
+            cmd = [ 
+                    Support.program("launch.sh"),
+                    "games", "--link", dir
+            ]
+        else:
+            cmd = [ dir + "/start.sh" ]
+        
+        if (exists(dir + "/base.conf")):
+            cmd.append("-conf")
+            cmd.append(dir + "/base.conf")
+        cmd.append("-conf")
+        cmd.append(dir + "/start.conf")
+        
+        # GoG launcher already does this:
+        # print("Running", game.name)
+        rep = run(cmd, cwd=dir, env=env)
+        if (ffile != ""):
+            ffile.unlink()
+        if (rep.returncode != 0):
+            print("\nError when running", game.name, ": RC", rep.returncode)
+
diff --git a/Gamiki/Support/SupportWin31.py b/Gamiki/Support/SupportWin31.py
new file mode 100644 (file)
index 0000000..9a85c05
--- /dev/null
@@ -0,0 +1,19 @@
+from os.path    import exists, realpath
+from subprocess import run
+from shutil     import which
+
+from .  import Support, SupportDos
+from .. import Game
+
+class SupportWin31(SupportDos):
+    """Supports Windows 3.1 games via DosBox."""
+
+    # TODO: must be tried befoer SupportDos
+            
+    def supports(self, game: Game):
+        return (exists(game.dir + "/start.conf")
+            and exists(game.dir + "/C/WINDOWS"))
+    
+    def get_link_dir(self, game: Game) -> str:
+        return game.library.dir
+
diff --git a/Gamiki/Support/__init__.py b/Gamiki/Support/__init__.py
new file mode 100644 (file)
index 0000000..7e87dbd
--- /dev/null
@@ -0,0 +1,4 @@
+from .Support      import Support
+from .SupportDos   import SupportDos
+from .SupportWin31 import SupportWin31
+from .SupportGog   import SupportGog
diff --git a/Gamiki/__init__.py b/Gamiki/__init__.py
new file mode 100644 (file)
index 0000000..db34ecc
--- /dev/null
@@ -0,0 +1,4 @@
+from .Library import Library
+from .Game    import Game
+from .Builder import Builder
+from .Support import Support
diff --git a/Main.py b/Main.py
new file mode 100755 (executable)
index 0000000..e4472ef
--- /dev/null
+++ b/Main.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+
+import argparse
+from os.path import basename
+from sys import stderr
+
+from Gamiki import Builder, Library
+
+parser = argparse.ArgumentParser(
+    description="Game launcher and simple management system."
+)
+
+# Global actions
+global_actions = parser.add_argument_group('global actions')
+global_actions.add_argument("--list", 
+    help="list all known games by temporary number and name", 
+    action="store_true"
+)
+global_actions.add_argument("--list-tag", 
+    help="list all known games and their source tag",
+    action="store_true"
+)
+global_actions.add_argument("--list-tags", 
+    help="list all known games and their tags",
+    action="store_true"
+)
+
+# Select a game
+game_selection = parser.add_argument_group('game selection')
+game_selection.add_argument("GAME", 
+    help="Start a game (code, name or index -- may use RegEx)",
+    nargs='?', default=""
+)
+
+# Start a game or options on a game
+game_actions = parser.add_argument_group('actions on a GAME')
+game_actions.add_argument("--info", 
+    help="get information about a specific game",
+    metavar="GAME"
+)
+game_actions.add_argument("--info-name", 
+    help="get the name of the game",
+    metavar="GAME"
+)
+game_actions.add_argument("--info-code", 
+    help="get the code (short name) of the game",
+    metavar="GAME"
+)
+game_actions.add_argument("--info-desc", 
+    help="get the description of the game",
+    metavar="GAME"
+)
+game_actions.add_argument("--info-tags", 
+    help="get all the tags of the game, one per line",
+    metavar="GAME"
+)
+game_actions.add_argument("--info-icon", 
+    help="get the icon of the game",
+    metavar="GAME"
+)
+
+args = parser.parse_args()
+
+builder = Builder()
+if (args.list):
+    builder.list()
+    exit(0)
+elif (args.list_tag):
+    builder.list_tag()
+    exit(0)
+elif (args.list_tags):
+    builder.list_tags()
+    exit(0)
+
+g = ""
+if (args.info):
+    g = args.info
+elif (args.info_name):
+    g = args.info_name
+elif (args.info_code):
+    g = args.info_code
+elif (args.info_desc):
+    g = args.info_desc
+elif (args.info_tags):
+    g = args.info_tags
+elif (args.info_icon):
+    g = args.info_icon
+else:
+    g = args.GAME
+
+if (g == ""):
+    parser.print_usage()
+    print(
+            basename(__file__) + ": error:", 
+            "global action or GAME required", 
+            file=stderr
+    ) 
+    exit(1)
+
+try:
+    game = builder.find(g)
+except ValueError as err:
+    print(err, file=stderr)
+    exit(3)
+
+if (game == None):
+    print("Game not found:", g, file=stderr)
+    exit(2)
+
+if (args.info):
+    for (k, v) in game.items():
+        print(k + " =", v)
+elif (args.info_name):
+    print(game.name)
+elif (args.info_code):
+    print(game.code)
+elif (args.info_desc):
+    print(game.desc)
+elif (args.info_tags):
+    for tag in game.tags:
+        print(tag)
+elif (args.info_icon):
+    if ("Icon" in game):
+        print(game["Icon"])
+else:
+    game.start()
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..e69de29