commit 3c70f140ddf762cb9cc34c34de035552dc57ce3f Author: Auric Vente Date: Thu Aug 1 22:11:15 2024 -0600 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60bb0ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/* +.directory +*.pyc +__pycache__/ +.mypy_cache/ +cromulant.db \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a62b70d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.0 + hooks: + - id: mypy + files: "^cromulant/.*" + args: [--strict, --strict, --strict, cromulant/main.py] +- repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.5.5 + hooks: + - id: ruff + name: ruff check + entry: ruff check + files: ^cromulant/ + - id: ruff + name: ruff format + entry: ruff format + files: ^cromulant/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7ce453 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +Copyright madprops - All Rights Reserved \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d67c19 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Cromulant + + + +[Click here for screenshots](screenshots.md) + +## What is this? + +This is a kind of toy you can use for your amusement. + +It requires minimal interaction, most things happen automatically. + +You might want to keep it running in some tiled layout. + +## Usage + +You start with a set of `25` to `250` random ants (`100` by default). + +You can specify this anytime through `Restart`. When you restart everything resets to zero like triumphs and hits. + +There are `1000` names available. This is used as the pool of names to select randomly. + +Every x minutes or seconds a new update from a random ant appears. + +The content of the update depends on a random number. + +It can be a triumph, a hit, travel, thought, sentence. + +The ant with the highest score is shown in the footer. + +Ants get merged and replaced over time. + +All of this happens automatically, though you can manually force actions +by using the mouse on the portraits or main menu. Try click and middle click. + +Read [Algorithm](#algorithm) for more information about the mechanics. + +## Installation + +First make sure you have `qt` installed in your system. + +In arch you can do this with: `sudo pacman -S qt6-base` + +In ubuntu/debian you can do this with: `sudo apt install qt6-base-dev` + +### Quick Installation + +If you have `pipx` and `linux` installed you can use the following command: + +```sh +pipx install git+https://github.com/madprops/cromulant --force +``` + +### Advanced Installation + +1) Clone this repo. + +2) python -m venv venv + +3) venv/bin/pip install -r requirements.txt + +4) Use `run.sh` or `venv/bin/python -m cromulant.main` + +5) (Optional) Manually create desktop entries and icons for the application. + +## Algorithm + +A random ant is picked based on weights (oldest update date weighs more). +More weight means something is more likely to get picked. +Then a random number between 0 and length-of-methods-1 is picked. +For each number an action happens to produce an update. + +Words (sentences) have more weight compared to the rest of the update methods. +Some methods roll another number to pick the outcome like in the case of `think` +where there are 3 `think` types, these can also have custom weights. + +The top score is calculated on every new update. +The score is calculated as (Triumph - Hits). +If multiple ants have the same score, the oldest one wins. +The ant with the top score is shown in the footer. +The top ant uses a special portrait on updates. + +For merge, the words of each name are used. +They get filled with random words if less than 2 words. +One word from each set is picked randomly. +The triumph and hits from each ant get combined for the new ant. +The original ants get terminated and the merged one hatches. +An extra random ant is hatched to fill the gap. + +## Storage + +The state of ants is stored in `~/.local/share/cromulant/ants.json` + +The settings file is stored in `~/.config/cromulant/settings.json` + +Or the equivalents in non-linux systems. + +There is a command line argument to define a custom location for the ants state file. +This means you can have multiple states to save/load. + +There is a command line argument to define a custom location for the names list. +This means you can use this with another set of names. +If not enough names are provided the remaining ants are created with random words. + +## The Name + +I read the word [cromulent](https://www.merriam-webster.com/wordplay/what-does-cromulent-mean) being used somewhere which turned out to be invented by The Simpsons. + + +I created a new programming project to practice/study and tried to use that word for the name but made a typo. + +I liked the typo and made a game around it. + +--- + +[Command line arguments](arguments.md) + +[Click here for more](more.md) \ No newline at end of file diff --git a/arguments.md b/arguments.md new file mode 100644 index 0000000..2500a1b --- /dev/null +++ b/arguments.md @@ -0,0 +1,189 @@ +# Arguments + +Here are all the available command line arguments: + +--- + +### version + +Check the version of the program + +Action: version + +--- + +### names + +Path to a JSON file with a list of names. Use these instead of the default ones + +Type: str + +--- + +### ants + +Path to a JSON file with ants data. Use this instead of the default one + +Type: str + +--- + +### no-images + +Don't show the images on the left + +Action: store_false + +--- + +### no-header + +Don't show the header controls + +Action: store_false + +--- + +### no-footer + +Don't show the footer controls + +Action: store_false + +--- + +### no-intro + +Don't show the intro message + +Action: store_false + +--- + +### title + +Custom title for the window + +Default: [Empty string] + +Type: str + +--- + +### width + +The width of the window in pixels + +Default: 0 + +Type: int + +--- + +### height + +The height of the window in pixels + +Default: 0 + +Type: int + +--- + +### program + +The internal name of the program + +Default: [Empty string] + +Type: str + +--- + +### speed + +Use this update speed + +Default: [Empty string] + +Choices: "fast", "normal", "slow", "paused" + +Type: str + +--- + +### clean + +Start with clean ants data + +Default: False + +Action: store_true + +--- + +### fast-seconds + +The number of seconds between fast updates + +Default: 0 + +Type: int + +--- + +### normal-minutes + +The number of minutes between normal updates + +Default: 0.0 + +Type: float + +--- + +### slow-minutes + +The number of minutes between slow updates + +Default: 0.0 + +Type: float + +--- + +### argdoc + +Make the arguments document and exit + +Default: False + +Action: store_true + +--- + +### score + +Show the score on triumph or hits instead of the total of each + +Default: False + +Action: store_true + +--- + +### mono + +Use a monospace font + +Default: False + +Action: store_true + +--- + +### no-fade + +Don't apply a fade-in effect on new updates + +Action: store_false diff --git a/cromulant/__init__.py b/cromulant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cromulant/ants.py b/cromulant/ants.py new file mode 100644 index 0000000..2838c0f --- /dev/null +++ b/cromulant/ants.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import re +import random +import itertools +from typing import ClassVar, Any + +from .config import Config +from .args import Args +from .utils import Utils +from .storage import Storage + + +class Ant: + def __init__(self) -> None: + now = Utils.now() + self.created = now + self.updated = now + self.name = "" + self.status = "" + self.method = "hatched" + self.triumph = 0 + self.hits = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "created": self.created, + "updated": self.updated, + "name": self.name, + "status": self.status, + "method": self.method, + "hits": self.hits, + "triumph": self.triumph, + } + + def from_dict(self, data: dict[str, Any]) -> None: + self.created = data["created"] + self.updated = data["updated"] + self.name = data["name"] + self.status = data["status"] + self.method = data["method"] + self.hits = data["hits"] + self.triumph = data["triumph"] + + def get_name(self) -> str: + return self.name or "Nameless" + + def get_age(self) -> str: + now = Utils.now() + return Utils.time_ago(self.created, now) + + def describe(self) -> None: + Utils.print(f"Name is {self.get_name()}") + Utils.print(f"It hatched {self.get_age()}") + + def get_score(self) -> int: + return self.triumph - self.hits + + def tooltip(self) -> str: + tooltip = "" + tooltip += f"Updated: {Utils.to_date(self.updated)}" + tooltip += f"\nCreated: {Utils.to_date(self.created)}" + tooltip += f"\nTriumph: {self.triumph} | Hits: {self.hits}" + tooltip += "\nClick to Terminate" + tooltip += "\nMiddle Click to Merge" + return tooltip + + def get_status(self) -> str: + from .game import Method + + if (not self.status) and (not self.method): + return "No update yet" + + status = self.status + + if self.method == Method.triumph: + if Args.score: + total = f"(Score: {self.get_score()})" + else: + total = f"({self.triumph} total)" + + status = f"{Config.triumph_icon} {Config.triumph_message} {total}" + + elif self.method == Method.hit: + if Args.score: + total = f"(Score: {self.get_score()})" + else: + total = f"({self.hits} total)" + + status = f"{Config.hit_icon} {Config.hit_message} {total}" + + elif self.method == Method.think: + status = f"Thinking about {status}" + + elif self.method == Method.travel: + status = f"Traveling to {status}" + + return status + + +class Ants: + ants: ClassVar[list[Ant]] = [] + top: ClassVar[Ant | None] = None + + @staticmethod + def prepare() -> None: + Ants.get() + Ants.check() + Ants.get_top() + + @staticmethod + def check() -> None: + if not Ants.ants: + Ants.populate(Config.default_population) + + @staticmethod + def hatch(num: int = 1, ignore: list[str] | None = None) -> None: + from .game import Game + + for _ in range(num): + ant = Ant() + ant.name = Ants.random_name(ignore) + Ants.ants.append(ant) + Game.update(ant) + + Ants.on_change() + + @staticmethod + def on_change() -> None: + from .game import Game + + Ants.get_top() + Game.info() + Ants.save() + + @staticmethod + def random_ant(ignore: list[Ant] | None = None) -> Ant | None: + if ignore: + ants = [a for a in Ants.ants if a not in ignore] + else: + ants = Ants.ants + + return random.choice(ants) + + @staticmethod + def get_names() -> list[str]: + return [ant.name for ant in Ants.ants] + + @staticmethod + def save() -> None: + Storage.save_ants(Ants.ants) + + @staticmethod + def get_next() -> Ant | None: + now = Utils.now() + ages = [(now - ant.updated) for ant in Ants.ants] + + # Normalize ages to create weights + total_age = sum(ages) + + if total_age == 0: + weights = [1] * len(Ants.ants) # If all ages are zero, use equal weights + else: + weights = [ + int((age / total_age) * 1000) for age in ages + ] # Scale and cast to int + + # Perform weighted random selection + return random.choices(Ants.ants, weights=weights, k=1)[0] + + @staticmethod + def get_current() -> Ant | None: + return max(Ants.ants, key=lambda ant: ant.updated) + + @staticmethod + def set_status(ant: Ant, status: str, method: str) -> None: + from .game import Game + + status = status.strip() + ant.status = status + ant.method = method + ant.updated = Utils.now() + + Game.update(ant) + Ants.on_change() + + @staticmethod + def get() -> None: + if Args.clean: + objs = [] + else: + objs = Storage.get_ants() + + for obj in objs: + ant = Ant() + ant.from_dict(obj) + Ants.ants.append(ant) + + @staticmethod + def populate(num: int) -> None: + Ants.clear() + Ants.hatch(num) + + @staticmethod + def random_name(ignore: list[str] | None = None) -> str: + names = Ants.get_names() + + if ignore: + for name in ignore: + if name not in names: + names.append(name) + + return Utils.random_name(names) + + @staticmethod + def get_top() -> None: + top: Ant | None = None + top_score = 0 + + for ant in Ants.ants: + score = ant.get_score() + + if (not top) or (score > top_score): + top = ant + top_score = score + elif score == top_score: + if ant.created < top.created: + top = ant + + if not top: + return + + Ants.top = top + + @staticmethod + def merge(ant_1: Ant | None = None) -> bool: + from .game import Game + + def split(ant: Ant) -> list[str]: + return re.split(r"[ -]", ant.name) + + def remove(words: list[str], ignore: list[str]) -> list[str]: + return [word for word in words if word.lower() not in ignore] + + def fill(words: list[str]) -> list[str]: + words = remove(words, ["of", "de", "da", "the"]) + + if len(words) < 2: + n = random.randint(1, 2) + + if n == 1: + words = Utils.random_words(2 - len(words)) + else: + words = Utils.make_words(2 - len(words)) + + words.extend(words) + + return [Utils.capitalize(word) for word in words] + + if not ant_1: + ant_1 = Ants.random_ant() + + if not ant_1: + return False + + ant_2 = Ants.random_ant([ant_1]) + + if not ant_2: + return False + + words_1 = split(ant_1) + words_2 = split(ant_2) + words_1 = fill(words_1) + words_2 = fill(words_2) + + name = "" + names = Ants.get_names() + combinations = list(itertools.product(words_1, words_2)) + random.shuffle(combinations) + + for combo in combinations: + possible = f"{combo[0]} {combo[1]}" + + if (possible == ant_1.name) or (possible == ant_2.name): + continue + + if (possible in names) or (possible in Utils.names): + continue + + name = possible + break + + if not name: + return False + + Ants.set_terminated(ant_1) + Ants.set_terminated(ant_2) + + ant = Ant() + ant.name = name + ant.triumph = ant_1.triumph + ant_2.triumph + ant.hits = ant_1.hits + ant_2.hits + + Ants.ants.append(ant) + Game.update(ant) + Ants.hatch(ignore=[ant_1.name, ant_2.name]) + return True + + @staticmethod + def clear() -> None: + Ants.ants = [] + + @staticmethod + def terminate(ant: Ant) -> None: + Ants.set_terminated(ant) + Ants.hatch(ignore=[ant.name]) + + @staticmethod + def set_terminated(ant: Ant) -> None: + from .game import Game + + ant.method = "terminated" + Game.update(ant) + Ants.ants.remove(ant) diff --git a/cromulant/args.py b/cromulant/args.py new file mode 100644 index 0000000..5b39559 --- /dev/null +++ b/cromulant/args.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from .config import Config +from .utils import Utils +from .argspec import ArgSpec + + +class Args: + names: Path | None = None + ants: Path | None = None + images: bool = True + header: bool = True + footer: bool = True + intro: bool = True + title: str = "" + width: int = 0 + height: int = 0 + program: str = "" + speed: str = "" + clean: bool = False + fast_seconds: int = 0 + normal_minutes: float = 0.0 + slow_minutes: float = 0.0 + argdoc: bool = False + score: bool = False + mono: bool = False + fade: bool = True + + @staticmethod + def prepare() -> None: + ArgSpec.prepare() + ArgParser.prepare(Config.title, ArgSpec.arguments) + + for attr_name, attr_value in vars(Args).items(): + ArgSpec.defaults[attr_name] = attr_value + + other_name = [ + ("no_images", "images"), + ("no_header", "header"), + ("no_footer", "footer"), + ("no_intro", "intro"), + ("no_fade", "fade"), + ] + + for r_item in other_name: + ArgParser.get_value(*r_item) + + normals = [ + "title", + "width", + "height", + "program", + "speed", + "clean", + "fast_seconds", + "normal_minutes", + "slow_minutes", + "argdoc", + "score", + "mono", + ] + + for n_item in normals: + ArgParser.get_value(n_item) + + paths = [ + "names", + "ants", + ] + + for p_item in paths: + ArgParser.get_value(p_item, path=True) + + @staticmethod + def make_argdoc() -> None: + from .utils import Utils + from .storage import Storage + + text = Args.argtext() + Storage.save_arguments(text) + Utils.print("Saved arguments document") + + @staticmethod + def argtext(filter_text: str | None = None) -> str: + sep = "\n\n---\n\n" + text = "" + filter_lower = "" + + if not filter_text: + text = "# Arguments\n\n" + text += "Here are all the available command line arguments:" + else: + filter_lower = filter_text.lower() + + for key in ArgSpec.arguments: + if key == "string_arg": + continue + + arg = ArgSpec.arguments[key] + info = arg.get("help", "") + + if filter_text: + if filter_lower not in key.lower(): + if filter_lower not in info.lower(): + continue + + text += sep + name = key.replace("_", "-") + text += f"### {name}" + + if info: + text += "\n\n" + text += info + + defvalue = ArgSpec.defaults.get(key) + + if defvalue is not None: + if isinstance(defvalue, str): + if defvalue == "": + defvalue = "[Empty string]" + elif defvalue.strip() == "": + spaces = defvalue.count(" ") + ds = Utils.singular_or_plural(spaces, "space", "spaces") + defvalue = f"[{spaces} {ds}]" + else: + defvalue = f'"{defvalue}"' + + text += "\n\n" + text += f"Default: {defvalue}" + + choices = arg.get("choices", []) + + if choices: + text += "\n\n" + text += "Choices: " + + choicestr = [ + f'"{choice}"' if isinstance(choice, str) else choice + for choice in choices + ] + + text += ", ".join(choicestr) + + action = arg.get("action", "") + + if action: + text += "\n\n" + text += f"Action: {action}" + + argtype = arg.get("type", "") + + if argtype: + text += "\n\n" + text += f"Type: {argtype.__name__}" + + text += "\n" + return text.lstrip() + + +class ArgParser: + parser: argparse.ArgumentParser + args: argparse.Namespace + + @staticmethod + def prepare(title: str, argdefs: dict[str, Any]) -> None: + parser = argparse.ArgumentParser(description=title) + argdefs["string_arg"] = {"nargs": "*"} + + for key in argdefs: + item = argdefs[key] + + if key == "string_arg": + name = key + else: + name = ArgParser.under_to_dash(key) + name = f"--{name}" + + tail = {key: value for key, value in item.items() if value is not None} + parser.add_argument(name, **tail) + + ArgParser.parser = parser + ArgParser.args = parser.parse_args() + + @staticmethod + def string_arg() -> str: + return " ".join(ArgParser.args.string_arg) + + @staticmethod + def get_value( + attr: str, key: str | None = None, no_strip: bool = False, path: bool = False + ) -> None: + value = getattr(ArgParser.args, attr) + + if value is not None: + if not no_strip: + if isinstance(value, str): + value = value.strip() + + obj = key if key else attr + + if path: + value = Path(value) + + ArgParser.set(obj, value) + + @staticmethod + def set(attr: str, value: Any) -> None: + setattr(Args, attr, value) + + @staticmethod + def under_to_dash(s: str) -> str: + return s.replace("_", "-") diff --git a/cromulant/argspec.py b/cromulant/argspec.py new file mode 100644 index 0000000..9059824 --- /dev/null +++ b/cromulant/argspec.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from typing import Any + +from .config import Config + + +class DuplicateArgumentError(Exception): + def __init__(self, key: str) -> None: + self.message = f"Duplicate argument: {key}" + + def __str__(self) -> str: + return self.message + + +class MissingInfoError(Exception): + def __init__(self, key: str) -> None: + self.message = f"Missing info for argument: {key}" + + def __str__(self) -> str: + return self.message + + +class DuplicateInfoError(Exception): + def __init__(self, key: str) -> None: + self.message = f"Duplicate info for argument: {key}" + + def __str__(self) -> str: + return self.message + + +class ArgSpec: + vinfo: str + defaults: dict[str, Any] + arguments: dict[str, Any] + infos: list[str] + + @staticmethod + def prepare() -> None: + ArgSpec.vinfo = f"{Config.title} {Config.version}" + ArgSpec.defaults = {} + ArgSpec.arguments = {} + ArgSpec.infos = [] + ArgSpec.add_arguments() + + @staticmethod + def add_argument(key: str, info: str, **kwargs: Any) -> None: + if key in ArgSpec.arguments: + raise DuplicateArgumentError(key) + + if not info: + raise MissingInfoError(key) + + if info in ArgSpec.infos: + raise DuplicateInfoError(key) + + ArgSpec.arguments[key] = { + "help": info, + **kwargs, + } + + ArgSpec.infos.append(info) + + @staticmethod + def add_arguments() -> None: + ArgSpec.add_argument( + "version", + action="version", + info="Check the version of the program", + version=ArgSpec.vinfo, + ) + + ArgSpec.add_argument( + "names", + type=str, + info="Path to a JSON file with a list of names. Use these instead of the default ones", + ) + + ArgSpec.add_argument( + "ants", + type=str, + info="Path to a JSON file with ants data. Use this instead of the default one", + ) + + ArgSpec.add_argument( + "no_images", + action="store_false", + info="Don't show the images on the left", + ) + + ArgSpec.add_argument( + "no_header", + action="store_false", + info="Don't show the header controls", + ) + + ArgSpec.add_argument( + "no_footer", + action="store_false", + info="Don't show the footer controls", + ) + + ArgSpec.add_argument( + "no_intro", + action="store_false", + info="Don't show the intro message", + ) + + ArgSpec.add_argument( + "title", + type=str, + info="Custom title for the window", + ) + + ArgSpec.add_argument( + "width", + type=int, + info="The width of the window in pixels", + ) + + ArgSpec.add_argument( + "height", + type=int, + info="The height of the window in pixels", + ) + + ArgSpec.add_argument( + "program", + type=str, + info="The internal name of the program", + ) + + ArgSpec.add_argument( + "speed", + type=str, + choices=["fast", "normal", "slow", "paused"], + info="Use this update speed", + ) + + ArgSpec.add_argument( + "clean", + action="store_true", + info="Start with clean ants data", + ) + + ArgSpec.add_argument( + "fast_seconds", + type=int, + info="The number of seconds between fast updates", + ) + + ArgSpec.add_argument( + "normal_minutes", + type=float, + info="The number of minutes between normal updates", + ) + + ArgSpec.add_argument( + "slow_minutes", + type=float, + info="The number of minutes between slow updates", + ) + + ArgSpec.add_argument( + "argdoc", + action="store_true", + info="Make the arguments document and exit", + ) + + ArgSpec.add_argument( + "score", + action="store_true", + info="Show the score on triumph or hits instead of the total of each", + ) + + ArgSpec.add_argument( + "mono", + action="store_true", + info="Use a monospace font", + ) + + ArgSpec.add_argument( + "no_fade", + action="store_false", + info="Don't apply a fade-in effect on new updates", + ) diff --git a/cromulant/audio/March of the Cyber Ants.mp3 b/cromulant/audio/March of the Cyber Ants.mp3 new file mode 100644 index 0000000..29a4215 Binary files /dev/null and b/cromulant/audio/March of the Cyber Ants.mp3 differ diff --git a/cromulant/config.py b/cromulant/config.py new file mode 100644 index 0000000..a723812 --- /dev/null +++ b/cromulant/config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pathlib import Path + +import appdirs # type: ignore + + +class Config: + program: str + title: str + version: str + width: int = 820 + height: int = 900 + here: Path + ants_json: Path + icon_path: Path + status_image_path: Path + hatched_image_path: Path + top_image_path: Path + terminated_image_path: Path + names_json: Path + background_color: str = "rgb(44, 44, 44)" + text_color: str = "#ffffff" + image_size: int = 80 + space_1: int = 18 + max_updates: int = 300 + fast_seconds: int = 5 + normal_minutes: float = 1 + slow_minutes: float = 5 + font_size: int = 20 + info_separator: str = " - " + font_path: Path + emoji_font_path: Path + mono_font_path: Path + triumph_color: tuple[int, int, int] = (255, 255, 0) + hit_color: tuple[int, int, int] = (255, 0, 77) + triumph_icon: str = "😀" + hit_icon: str = "🎃" + triumph_message: str = "Scored a triumph" + hit_message: str = "Took a hit" + song_path: Path + logo_path: Path + alt_background_color: str = "rgb(33, 33, 33)" + alt_text_color: str = "white" + alt_hover_background_color: str = "rgb(51, 51, 51)" + alt_hover_text_color: str = "white" + alt_border_color: str = "rgb(88, 88, 88)" + message_box_button_hover_background_color: str = "rgb(66, 66, 66)" + message_box_button_hover_text_color: str = "white" + scrollbar_handle_color: str = "rgb(69, 69, 69)" + input_background_color: str = "rgb(111, 111, 111)" + input_text_color: str = "black" + input_border_color: str = "rgb(120, 120, 120)" + input_caret_color: str = "rgb(18, 18, 18)" + settings_json: Path + countries_json: Path + filter_debouncer_delay: int = 200 + default_population: int = 100 + merge_goal: int = 10 + manifest_path: Path + manifest: dict[str, str] + icon_on: str = "✅" + icon_off: str = "❌" + ant: str = "🐜" + arguments_path: Path + fade_duration: int = 500 + + @staticmethod + def prepare() -> None: + from .storage import Storage + + Config.here = Path(__file__).parent + Config.manifest_path = Config.here / "manifest.json" + Config.manifest = Storage.get_manifest() + Config.title = Config.manifest["title"] + Config.program = Config.manifest["program"] + Config.version = Config.manifest["version"] + + Config.ants_json = Path(appdirs.user_data_dir()) / Config.program / "ants.json" + + if not Config.ants_json.exists(): + Config.ants_json.parent.mkdir(parents=True, exist_ok=True) + Config.ants_json.write_text("[]") + + Config.settings_json = ( + Path(appdirs.user_config_dir()) / Config.program / "settings.json" + ) + + if not Config.settings_json.exists(): + Config.settings_json.parent.mkdir(parents=True, exist_ok=True) + Config.settings_json.write_text("{}") + + Config.names_json = Config.here / "data" / "names.json" + Config.countries_json = Config.here / "data" / "countries.json" + Config.icon_path = Config.here / "img" / "icon.jpg" + Config.status_image_path = Config.here / "img" / "status.jpg" + Config.hatched_image_path = Config.here / "img" / "hatched.jpg" + Config.top_image_path = Config.here / "img" / "top.jpg" + Config.terminated_image_path = Config.here / "img" / "terminated.jpg" + Config.font_path = Config.here / "fonts" / "NotoSans-Regular.ttf" + Config.emoji_font_path = Config.here / "fonts" / "NotoEmoji-Regular.ttf" + Config.mono_font_path = Config.here / "fonts" / "NotoSansMono-Regular.ttf" + Config.song_path = Config.here / "audio" / "March of the Cyber Ants.mp3" + Config.logo_path = Config.here / "img" / "logo_3.jpg" + Config.arguments_path = Config.here / ".." / "arguments.md" diff --git a/cromulant/data/countries.json b/cromulant/data/countries.json new file mode 100644 index 0000000..b42b5dc --- /dev/null +++ b/cromulant/data/countries.json @@ -0,0 +1,250 @@ +[ + "Afghanistan", + "Albania", + "Algeria", + "American Samoa", + "Andorra", + "Angola", + "Anguilla", + "Antarctica", + "Antigua and Barbuda", + "Argentina", + "Armenia", + "Aruba", + "Australia", + "Austria", + "Azerbaijan", + "Bahamas", + "Bahrain", + "Bangladesh", + "Barbados", + "Belarus", + "Belgium", + "Belize", + "Benin", + "Bermuda", + "Bhutan", + "Bolivia", + "Bosnia and Herzegovina", + "Botswana", + "Bouvet Island", + "Brazil", + "British Indian Ocean Territory", + "Brunei", + "Bulgaria", + "Burkina Faso", + "Burundi", + "Cambodia", + "Cameroon", + "Canada", + "Cape Verde", + "Cayman Islands", + "Central African Republic", + "Chad", + "Chile", + "China", + "Christmas Island", + "Cocos (Keeling) Islands", + "Colombia", + "Comoros", + "Congo", + "The Democratic Republic of Congo", + "Cook Islands", + "Costa Rica", + "Ivory Coast", + "Croatia", + "Cuba", + "Cyprus", + "Czech Republic", + "Denmark", + "Djibouti", + "Dominica", + "Dominican Republic", + "East Timor", + "Ecuador", + "Egypt", + "England", + "El Salvador", + "Equatorial Guinea", + "Eritrea", + "Estonia", + "Eswatini", + "Ethiopia", + "Falkland Islands", + "Faroe Islands", + "Fiji Islands", + "Finland", + "France", + "French Guiana", + "French Polynesia", + "French Southern territories", + "Gabon", + "Gambia", + "Georgia", + "Germany", + "Ghana", + "Gibraltar", + "Greece", + "Greenland", + "Grenada", + "Guadeloupe", + "Guam", + "Guatemala", + "Guernsey", + "Guinea", + "Guinea-Bissau", + "Guyana", + "Haiti", + "Heard Island and McDonald Islands", + "Holy See (Vatican City State)", + "Honduras", + "Hong Kong", + "Hungary", + "Iceland", + "India", + "Indonesia", + "Iran", + "Iraq", + "Ireland", + "Israel", + "Isle of Man", + "Italy", + "Jamaica", + "Japan", + "Jersey", + "Jordan", + "Kazakhstan", + "Kenya", + "Kiribati", + "Kuwait", + "Kyrgyzstan", + "Laos", + "Latvia", + "Lebanon", + "Lesotho", + "Liberia", + "Libya", + "Liechtenstein", + "Lithuania", + "Luxembourg", + "Macao", + "North Macedonia", + "Madagascar", + "Malawi", + "Malaysia", + "Maldives", + "Mali", + "Malta", + "Marshall Islands", + "Martinique", + "Mauritania", + "Mauritius", + "Mayotte", + "Mexico", + "Micronesia, Federated States of", + "Moldova", + "Monaco", + "Mongolia", + "Montserrat", + "Montenegro", + "Morocco", + "Mozambique", + "Myanmar", + "Namibia", + "Nauru", + "Nepal", + "Netherlands", + "Netherlands Antilles", + "New Caledonia", + "New Zealand", + "Nicaragua", + "Niger", + "Nigeria", + "Niue", + "Norfolk Island", + "North Korea", + "Northern Ireland", + "Northern Mariana Islands", + "Norway", + "Oman", + "Pakistan", + "Palau", + "Palestine", + "Panama", + "Papua New Guinea", + "Paraguay", + "Peru", + "Philippines", + "Pitcairn", + "Poland", + "Portugal", + "Puerto Rico", + "Qatar", + "Reunion", + "Romania", + "Russia", + "Rwanda", + "Saint Helena", + "Saint Kitts and Nevis", + "Saint Lucia", + "Saint Pierre and Miquelon", + "Saint Vincent and the Grenadines", + "Samoa", + "San Marino", + "Sao Tome and Principe", + "Saudi Arabia", + "Scotland", + "Senegal", + "Serbia", + "Seychelles", + "Sierra Leone", + "Singapore", + "Slovakia", + "Slovenia", + "Solomon Islands", + "Somalia", + "South Africa", + "South Georgia and the South Sandwich Islands", + "South Korea", + "South Sudan", + "Spain", + "Sri Lanka", + "Sudan", + "Suriname", + "Svalbard and Jan Mayen", + "Sweden", + "Switzerland", + "Syria", + "Tajikistan", + "Tanzania", + "Thailand", + "Timor-Leste", + "Togo", + "Tokelau", + "Tonga", + "Trinidad and Tobago", + "Tunisia", + "Turkey", + "Turkmenistan", + "Turks and Caicos Islands", + "Tuvalu", + "Uganda", + "Ukraine", + "United Arab Emirates", + "United Kingdom", + "United States", + "United States Minor Outlying Islands", + "Uruguay", + "Uzbekistan", + "Vanuatu", + "Venezuela", + "Vietnam", + "Virgin Islands, British", + "Virgin Islands, U.S.", + "Wales", + "Wallis and Futuna", + "Western Sahara", + "Yemen", + "Zambia", + "Zimbabwe" +] \ No newline at end of file diff --git a/cromulant/data/names.json b/cromulant/data/names.json new file mode 100644 index 0000000..7649ada --- /dev/null +++ b/cromulant/data/names.json @@ -0,0 +1,1002 @@ +[ + "Aristotle", + "Plato", + "Jesus Christ", + "Socrates", + "Alexander the Great", + "Leonardo da Vinci", + "Confucius", + "Julius Caesar", + "Homer", + "Pythagoras", + "Archimedes", + "Moses", + "Muhammad", + "Abraham", + "Adolf Hitler", + "Wolfgang Amadeus Mozart", + "Charlemagne", + "William Shakespeare", + "Michelangelo", + "Augustus", + "Napoleon Bonaparte", + "Isaac Newton", + "Albert Einstein", + "Christopher Columbus", + "Johann Sebastian Bach", + "Dante Alighieri", + "Ludwig van Beethoven", + "Gautama Buddha", + "Cleopatra VII of Egypt", + "Martin Luther", + "Galileo Galilei", + "Herodotus", + "Paul of Tarsus", + "Augustine of Hippo", + "Euclid", + "Virgil", + "Nero", + "Thales", + "Karl Marx", + "Marco Polo", + "Cicero", + "Sophocles", + "Vincent van Gogh", + "Genghis Khan", + "Zoroaster", + "Hannibal Barca", + "Immanuel Kant", + "Hippocrates", + "Sigmund Freud", + "Pablo Picasso", + "Nefertiti", + "Solomon", + "Jeanne d'Arc", + "Qin Shi Huang", + "Aesop", + "Epicurus", + "Caligula", + "René Descartes", + "Thomas Aquinas", + "Tutankhamun", + "Johann Wolfgang von Goethe", + "Marcus Aurelius", + "Voltaire", + "Ovid", + "Niccolò Machiavelli", + "Blaise Pascal", + "Attila the Hun", + "David", + "Raphael", + "Charles Darwin", + "Che Guevara", + "Jean-Jacques Rousseau", + "Laozi", + "Mahatma Gandhi", + "Saladin", + "Saint Peter", + "Sappho", + "Aeschylus", + "Ptolemy", + "John the Baptist", + "Trajan", + "Mary Magdalene", + "Heraclitus", + "Gilgamesh", + "Vasco da Gama", + "Adam Smith", + "Elizabeth I of England", + "Horace", + "Louis XIV of France", + "Anne Boleyn", + "Ferdinand Magellan", + "Joseph Stalin", + "Ramesses II", + "Vladimir Lenin", + "Leonidas I", + "Avicenna", + "Akhenaten", + "Johannes Gutenberg", + "Euripides", + "Seneca the Younger", + "Albrecht Dürer", + "Pericles", + "Sun Tzu", + "Hadrian", + "Judas Iscariot", + "Mark Antony", + "Hammurabi", + "John Locke", + "Democritus", + "Aristophanes", + "Martin Luther King, Jr.", + "Plutarch", + "Friedrich Nietzsche", + "Victor Hugo", + "Imhotep", + "Elvis Presley", + "Salvador Dalí", + "Richard Wagner", + "Constantine I", + "Molière", + "Benito Mussolini", + "Hesiod", + "Xerxes I of Persia", + "Tiberius", + "Oscar Wilde", + "Francisco Goya", + "Peter Paul Rubens", + "Claude Monet", + "Diogenes of Sinope", + "Diocletian", + "Eratosthenes", + "Frédéric Chopin", + "Henry VIII of England", + "Mao Zedong", + "Marie Antoinette", + "Carl Friedrich Gauss", + "Giuseppe Verdi", + "Otto von Bismarck", + "Caravaggio", + "Isaac", + "Justinian I", + "Marilyn Monroe", + "Georg Wilhelm Friedrich Hegel", + "Sandro Botticelli", + "Giordano Bruno", + "Cyrus the Great", + "Louis Pasteur", + "Commodus", + "Spartacus", + "George Frideric Handel", + "Carl Linnaeus", + "Nostradamus", + "Darius I of Persia", + "Xenophon", + "Petrarch", + "Giovanni Boccaccio", + "Fyodor Dostoyevsky", + "Theodosius I", + "Elijah", + "Solon", + "Strabo", + "Max Weber", + "Leo Tolstoy", + "Thomas Hobbes", + "Khufu", + "Thucydides", + "Gottfried Wilhelm von Leibniz", + "Tacitus", + "John Calvin", + "Walt Disney", + "Thomas More", + "Le Corbusier", + "Vespasian", + "Jean-Paul Sartre", + "Johannes Kepler", + "Aleksandr Pushkin", + "Anaximander", + "Desiderius Erasmus", + "Suleiman the Magnificent", + "Winston Churchill", + "Nebuchadrezzar II", + "Parmenides", + "Johannes Vermeer", + "Ali", + "James Cook", + "Benjamin Franklin", + "Edgar Allan Poe", + "Joseph Haydn", + "Honoré de Balzac", + "Li Bai", + "Giotto di Bondone", + "George Washington", + "Donatello", + "Thomas Edison", + "Galen", + "Titian", + "Baruch Spinoza", + "Francis of Assisi", + "James Watt", + "Timur", + "Claudius", + "Sima Qian", + "Anton Chekhov", + "Protagoras", + "Hernán Cortés", + "Nicolaus Copernicus", + "Abraham Lincoln", + "Michael Faraday", + "Jimi Hendrix", + "Andy Warhol", + "Methuselah", + "Du Fu", + "Antonio Vivaldi", + "Phidias", + "Zeno of Elea", + "Empedocles", + "Mother Teresa", + "El Greco", + "Paracelsus", + "Bob Marley", + "Anaxagoras", + "Clovis I", + "Miguel de Cervantes", + "Philip II of Macedon", + "Charles V, Holy Roman Emperor", + "Marcel Proust", + "Plotinus", + "Averroes", + "Leonardo Fibonacci", + "Hieronymus Bosch", + "David Hume", + "Bruce Lee", + "Pompey", + "Charles de Gaulle", + "Septimius Severus", + "Pliny the Elder", + "Peter I of Russia", + "Bob Dylan", + "Antoninus Pius", + "Arthur Schopenhauer", + "Francisco Franco", + "Charlie Chaplin", + "Josephus", + "Hermann Hesse", + "Caracalla", + "Friedrich Engels", + "Saint Andrew", + "Rembrandt", + "William Wallace", + "Marcus Vitruvius Pollio", + "Paul Gauguin", + "Vercingetorix", + "Saint George", + "Heinrich Himmler", + "Pindar", + "Louis XVI of France", + "Johannes Brahms", + "Saint James the Great", + "John F. Kennedy", + "George Orwell", + "Mustafa Kemal Atatürk", + "Charles Martel", + "Thomas Mann", + "Diogenes Laertius", + "Marcus Vipsanius Agrippa", + "Pyrrhus of Epirus", + "Omar Khayyám", + "Alfred Nobel", + "Queen of Sheba", + "Marcus Junius Brutus", + "Donatien Alphonse François de Sade", + "Romulus Augustus", + "Demosthenes", + "James Joyce", + "Francis Bacon", + "Johann Christoph Friedrich von Schiller", + "Fidel Castro", + "Mark Twain", + "Joseph Goebbels", + "Marie Curie", + "Aaron", + "Richard I of England", + "Lucius Cornelius Sulla", + "Bertolt Brecht", + "Pontius Pilate", + "Ernest Hemingway", + "Muhammad ibn Musa al-Khwarizmi", + "Jan Hus", + "Charles Baudelaire", + "Saddam Hussein", + "Hans Christian Andersen", + "Mary I of Scotland", + "Umar", + "John Maynard Keynes", + "Antonín Dvořák", + "Darius III of Persia", + "Ludwig Wittgenstein", + "Cato the Elder", + "Stanley Kubrick", + "Lucretius", + "Jack the Ripper", + "Cao Cao", + "Francis Drake", + "Nicholas II of Russia", + "Frida Kahlo", + "Amerigo Vespucci", + "Gabriel García Márquez", + "Catullus", + "Plautus", + "John Lennon", + "Narmer", + "Hypatia of Alexandria", + "Guan Yu", + "Thomas Jefferson", + "Daniel Defoe", + "Hermann Göring", + "Vlad III the Impaler", + "Catherine II of Russia", + "Napoleon III of France", + "Pierre-Auguste Renoir", + "Marlon Brando", + "Pelé", + "Pope John Paul II", + "Bertrand Russell", + "Hero of Alexandria", + "Polybius", + "Herod the Great", + "Émile Zola", + "Pippin the Younger", + "Henry Ford", + "Thutmose III", + "Denis Diderot", + "Charles de Secondat, baron de Montesquieu", + "Giacomo Casanova", + "James, son of Alphaeus", + "Oliver Cromwell", + "Djoser", + "Ptolemy I Soter", + "Franklin D. Roosevelt", + "Henri Matisse", + "Henrik Ibsen", + "Gaius Marius", + "Kublai Khan", + "Horatio Nelson, 1st Viscount Nelson", + "Zhuge Liang", + "Otho", + "Job", + "Ishmael", + "Anaximenes of Miletus", + "Hugh Capet of France", + "Arthur Rimbaud", + "Vitellius", + "Francisco Pizarro", + "Nikola Tesla", + "Alessandro Volta", + "Benedict of Nursia", + "Galba", + "Pierre Abélard", + "Mikhail Gorbachev", + "Oda Nobunaga", + "Odoacer", + "Jerome", + "Gian Lorenzo Bernini", + "Édith Piaf", + "Elizabeth II of the United Kingdom", + "Max Planck", + "William Blake", + "Johnny Cash", + "Ingmar Bergman", + "Suetonius", + "Leon Trotsky", + "Gorgias", + "Titus", + "Japheth", + "Philip II of Spain", + "Ivan IV of Russia", + "Émile Durkheim", + "Auguste Comte", + "Michel Foucault", + "Louis Armstrong", + "Carl Jung", + "Édouard Manet", + "Stephen King", + "Stendhal", + "Giacomo Puccini", + "Al Pacino", + "Josip Broz Tito", + "Louis the Pious", + "Khafra", + "Marlene Dietrich", + "Aristarchus of Samos", + "Gustav Mahler", + "Pablo Neruda", + "Ronald Reagan", + "Isaac Asimov", + "Eugène Delacroix", + "Mary I of England", + "Luke the Evangelist", + "Franz Kafka", + "Hirohito", + "Origen", + "Xenophanes", + "Mencius", + "Rachel", + "Erwin Rommel", + "Hokusai", + "Abu Bakr", + "Karl Popper", + "Maximilien Robespierre", + "Arthur Conan Doyle", + "Tertullian", + "Ramesses I", + "Woody Allen", + "Alfred Hitchcock", + "Frank Lloyd Wright", + "Frank Sinatra", + "Nikolai Gogol", + "Michel de Montaigne", + "Jan van Eyck", + "Rosa Luxemburg", + "Audrey Hepburn", + "Coco Chanel", + "Robert Alexander Schumann", + "Dmitri Mendeleev", + "Frederick I, Holy Roman Emperor", + "Tokugawa Ieyasu", + "Pyotr Ilyich Tchaikovsky", + "Franz Schubert", + "Simón Bolívar", + "Pliny the Younger", + "Jean de La Fontaine", + "Pope Gregory I", + "Seti I", + "John of England", + "Epictetus", + "Jean Piaget", + "Saint Nicholas", + "Clint Eastwood", + "Saint Joseph", + "John Forbes Nash", + "Aurelian", + "George Gordon Noel Byron, 6th Baron Byron", + "Numa Pompilius", + "Antoine Lavoisier", + "Zheng He", + "Maria Theresa of Austria", + "Claudio Monteverdi", + "Anne Frank", + "Paul McCartney", + "Catherine of Aragon", + "Agatha Christie", + "El Cid", + "Maurice Ravel", + "Niccolò Paganini", + "Simone de Beauvoir", + "Wilhelm II", + "Franz Liszt", + "Louis XV of France", + "Mark the Evangelist", + "Alcibiades", + "Nelson Mandela", + "Wilhelm Conrad Röntgen", + "Mehmed II", + "Emperor Jimmu", + "John Dalton", + "Nikita Khrushchev", + "Maimonides", + "Constantius Chlorus", + "Luciano Pavarotti", + "Al Capone", + "Isidore of Seville", + "Edgar Degas", + "Stephen Hawking", + "Richard Nixon", + "Florence Nightingale", + "Anselm of Canterbury", + "Christiaan Huygens", + "Giuseppe Garibaldi", + "Edvard Grieg", + "Sarah", + "Dido", + "Samson", + "Theodoric the Great", + "Gallienus", + "Ibn Khaldun", + "Shem", + "Hildegard of Bingen", + "Mata Hari", + "Robert De Niro", + "Croesus", + "Umberto Eco", + "Zeno of Citium", + "Samuel Beckett", + "Jack Nicholson", + "Liu Bei", + "John Chrysostom", + "James Clerk Maxwell", + "Amenhotep III", + "Fu Hsi", + "George Bernard Shaw", + "Jack London", + "Arnold Schwarzenegger", + "Ernest Rutherford", + "Louis XIII of France", + "Saul the King", + "Yellow Emperor", + "Jonathan Swift", + "Alexander von Humboldt", + "Alexander Graham Bell", + "Tycho Brahe", + "Levi", + "Philip IV of France", + "Catherine de' Medici", + "John Stuart Mill", + "Terence", + "Hector Berlioz", + "Jane Austen", + "J. R. R. Tolkien", + "Edvard Munch", + "Jean Auguste Dominique Ingres", + "Eric Clapton", + "Roald Amundsen", + "Alexander Fleming", + "Praxiteles", + "Isaiah", + "Charles Dickens", + "Louis IX of France", + "Ambrose", + "Charles Perrault", + "Jalal ad-Din Muhammad Rumi", + "Macrinus", + "Apuleius", + "Enrico Fermi", + "John von Neumann", + "Heinrich Heine", + "Theophrastus", + "Astrid Lindgren", + "Silvio Berlusconi", + "Godfrey of Bouillon", + "Pol Pot", + "Alaric I", + "Noam Chomsky", + "Jacques-Louis David", + "Constantius II", + "Ignatius of Loyola", + "Leonid Brezhnev", + "Elizabeth Báthory", + "Jim Morrison", + "Antoni Gaudí", + "Felix Mendelssohn-Bartholdy", + "William the Conqueror", + "Arius", + "Jules Verne", + "Decius", + "Robert Koch", + "Johann Pachelbel", + "Robert Hooke", + "Boris Yeltsin", + "Ray Charles", + "Petronius", + "Arminius", + "Bernard of Clairvaux", + "Gaius Maecenas", + "Charles the Bald, Holy Roman Emperor", + "André-Marie Ampère", + "Henri Bergson", + "Chiang Kai-shek", + "Richard Strauss", + "Lewis Carroll", + "Ferdinand de Saussure", + "Chuck Norris", + "Albertus Magnus", + "Leucippus", + "Aleister Crowley", + "Evangelista Torricelli", + "Rudolf Steiner", + "Masaccio", + "Hatshepsut", + "Nefertari", + "Josef Mengele", + "Melchizedek", + "Menander", + "Pope Clement I", + "Filippo Brunelleschi", + "Ashoka", + "George Harrison", + "Andrea Palladio", + "Horemheb", + "Harry S. Truman", + "Maxim Gorky", + "Virginia Woolf", + "Quintilian", + "Roger Bacon", + "Bedřich Smetana", + "François Rabelais", + "Gordian III", + "Tintoretto", + "Esau", + "George Sand", + "Hipparchus", + "Juvenal", + "Anders Celsius", + "Sneferu", + "Bartholomäus", + "Hamilcar Barca", + "David Ricardo", + "Brigitte Bardot", + "Steven Spielberg", + "Pope Benedict XVI", + "Albert Camus", + "Dwight D. Eisenhower", + "Arcadius", + "Paul von Hindenburg", + "Federico García Lorca", + "Hor-Aha", + "Miyamoto Musashi", + "Huang Xian Fan", + "Belisarius", + "Snorri Sturluson", + "Rudyard Kipling", + "Geoffrey Chaucer", + "Diodorus Siculus", + "Ashurbanipal", + "Caspar David Friedrich", + "Georges Bizet", + "Julius Nepos", + "Al-Farabi", + "Adolf Eichmann", + "Saint Christopher", + "Lucius Tarquinius Superbus", + "Eusebius of Caesarea", + "William of Ockham", + "Saint Anne", + "Saint Jude", + "Sun Yat-sen", + "Cardinal Richelieu", + "Lech Wałęsa", + "Henri de Toulouse-Lautrec", + "Theodore Roosevelt", + "Alexander Severus", + "Erik the Red", + "Georg Ohm", + "Milan Kundera", + "Freddie Mercury", + "Ivan Pavlov", + "J. M. W. Turner", + "Gustave Eiffel", + "James Prescott Joule", + "Manetho", + "Pope Alexander VI", + "Federico Fellini", + "Ringo Starr", + "Theodosius II", + "Basil of Caesarea", + "Servius Tullius", + "Amedeo Modigliani", + "Osman I", + "Hayao Miyazaki", + "Joan Miró", + "Guglielmo Marconi", + "Sylvester Stallone", + "Ho Chi Minh", + "Piero della Francesca", + "Aisha bint Abu Bakr", + "Anthony the Great", + "Helena of Constantinople", + "Germanicus", + "Rainer Maria Rilke", + "Joshua", + "Grigori Rasputin", + "Robert Boyle", + "Henry III of France", + "Anacreon", + "Nimrod", + "Anton Bruckner", + "François Villon", + "Sargon of Akkad", + "Murasaki Shikibu", + "Heraclius", + "Lucius Verus", + "Henry IV of France", + "Fra Angelico", + "James Dean", + "Harun al-Rashid", + "Diego Velázquez", + "André Gide", + "Sarah Bernhardt", + "Merneptah", + "Comenius", + "Augusto Pinochet", + "Paul Verlaine", + "Helen Keller", + "Flavius Aëtius", + "Cambyses II of Persia", + "Jean Sibelius", + "Gustave Flaubert", + "Albert Schweitzer", + "Alfred the Great", + "Bartolomeu Dias", + "Karl Dönitz", + "Antisthenes", + "Epaminondas", + "Otto I, Holy Roman Emperor", + "Giorgione", + "Marcel Duchamp", + "Lady Godiva", + "Gustave Courbet", + "Sean Connery", + "Pierre-Simon Laplace", + "Warren Buffett", + "Knut Hamsun", + "Miles Davis", + "Nicolas Poussin", + "Erich Fromm", + "Tina Turner", + "Pieter Brueghel the Elder", + "Peisistratos", + "Jacques Offenbach", + "Jimmy Carter", + "Edward VI of England", + "Philip the Arab", + "Anicius Manlius Severinus Boethius", + "Toyotomi Hideyoshi", + "Claudius II", + "Pope Urban II", + "Catiline", + "Franz Joseph I of Austria", + "John Milton", + "Lady Jane Grey", + "Yasser Arafat", + "Henry Dunant", + "Leone Battista Alberti", + "Alexander IV of Macedon", + "Manco Capac", + "Walter Scott", + "Philip II of France", + "George H. W. Bush", + "Didius Julianus", + "William Faulkner", + "Eva Braun", + "Wassily Kandinsky", + "Menes", + "Jacques Cartier", + "Nagarjuna", + "Ibn Battuta", + "Pope Silvester I", + "Ammianus Marcellinus", + "Sallust", + "Al-Ghazali", + "Zhang Fei", + "Philo", + "Diophantus", + "Béla Bartók", + "Anthony of Padua", + "Catherine of Alexandria", + "H. P. Lovecraft", + "Joseph of Arimathea", + "Sophia Loren", + "Tullus Hostilius", + "John the Evangelist", + "Nicholas of Cusa", + "Antonio Salieri", + "Abu Nuwas", + "Agrippina the Younger", + "Octave Mirbeau", + "Julian the Apostate", + "Madame de Pompadour", + "Valentinian I", + "Ptolemy II Philadelphus", + "Carus", + "Carinus", + "Menkaura", + "Giuseppe Arcimboldo", + "Jean Racine", + "Kofi Annan", + "Alvar Aalto", + "Bill Gates", + "Thutmose I", + "Jean-Baptiste Lamarck", + "Janis Joplin", + "Antoine de Saint-Exupéry", + "Ivan Turgenev", + "Bernhard Riemann", + "Charles Bukowski", + "Georg Philipp Telemann", + "Wu Zetian", + "Batu Khan", + "Paul Klee", + "Guy de Maupassant", + "Themistocles", + "Erich Maria Remarque", + "Dio Cassius", + "Louis XVIII of France", + "Martin of Tours", + "Bernard Montgomery", + "Valens", + "Malcolm X", + "Anthony Hopkins", + "Rudolf Hess", + "Paolo Veronese", + "Caesarion", + "Cato the Younger", + "Anatole France", + "Gaozu of Han", + "Diogenes Apolloniates", + "Piet Mondrian", + "Francis I of France", + "Frank Zappa", + "Nasreddin", + "Shennong", + "Irenaeus", + "Uthman ibn Affan", + "Reinhard Heydrich", + "John Wayne", + "Pyrrho", + "Mikhail Bakunin", + "Emily Brontë", + "Milton Friedman", + "Trebonianus Gallus", + "Woodrow Wilson", + "Clement of Alexandria", + "Bill Clinton", + "Thomas the Apostle", + "Abraham Maslow", + "Jacques Chirac", + "Edmund Husserl", + "Joseph Conrad", + "Pepi II Neferkare", + "Rudolf Diesel", + "Domenico Scarlatti", + "Ennio Morricone", + "Polykleitos", + "Bathsheba", + "Akihito", + "Antonio Stradivari", + "Yuri Andropov", + "Seleucus I Nicator", + "Wilhelm Keitel", + "Elagabalus", + "Ancus Marcius", + "Lysippos", + "Leif Ericson", + "Quintillus", + "Søren Kierkegaard", + "Sitting Bull", + "Robert Baden-Powell", + "Philip the Apostle", + "Sergei Rachmaninoff", + "Eva Perón", + "Orson Welles", + "Skanderbeg", + "Cleisthenes", + "Jean Cocteau", + "Vladimir Vladimirovich Nabokov", + "Jürgen Habermas", + "Simon the Zealot", + "Akira Kurosawa", + "Pierre Curie", + "Pope Gregory VII", + "Alexander I of Russia", + "Stefan Zweig", + "Darius II of Persia", + "Dmitri Shostakovich", + "Maurice Maeterlinck", + "Antigonus I Monophthalmus", + "Leonhard Euler", + "Eleanor of Aquitaine", + "Martial", + "Emperor Wu of Han", + "Artaxerxes II of Persia", + "Leni Riefenstahl", + "Guillaume Apollinaire", + "John Dewey", + "George Berkeley", + "Lucian", + "Pope Innocent III", + "Camille Pissarro", + "Ulfilas", + "Roxelana", + "Paulo Coelho", + "Ahmose I", + "Theodor Mommsen", + "Archduke Franz Ferdinand of Austria", + "Erik Satie", + "Alexander II of Russia", + "James Brown", + "Leonard Cohen", + "Greta Garbo", + "Girolamo Savonarola", + "Cassander", + "Pope Julius II", + "Valentinian III", + "Alexander Nevsky", + "Chuck Berry", + "Edward I of England", + "John Steinbeck", + "George Gershwin", + "Ferdowsi", + "Henry Purcell", + "Oskar Schindler", + "Thomas Malthus", + "Muhammad Ali", + "Johann Gottlieb Fichte", + "Dustin Hoffman", + "Juan Carlos I of Spain", + "Donato Bramante", + "Salvador Allende", + "Harrison Ford", + "Neil Armstrong", + "Antoine Henri Becquerel", + "Charles V of France", + "Padmasambhava", + "Bayezid I", + "Jimmy Page", + "Jean-Baptiste Lully", + "Camille Saint-Saëns", + "Basil II", + "Roxana", + "Gustave Doré", + "David Bowie", + "Giorgio Vasari", + "Antiochus III the Great", + "Heinrich Böll", + "Martin Bormann", + "Aristippus", + "Isocrates", + "Djer", + "Ingrid Bergman", + "Ludwig Andreas Feuerbach", + "Seth", + "Novalis", + "Carl Maria von Weber", + "Romain Rolland", + "David Hilbert", + "Cimabue", + "Pertinax", + "Tomaso Albinoni", + "Frederick II, Holy Roman Emperor", + "Apelles", + "Hu Jintao", + "Marcus Terentius Varro", + "Pier Paolo Pasolini", + "Charles Manson", + "Absalom", + "Modest Petrovich Mussorgsky", + "Alain Delon", + "Ozzy Osbourne", + "Lysimachus", + "Archilochus", + "Giovanni Pierluigi da Palestrina", + "Herod Antipas", + "Charles Babbage", + "Joséphine de Beauharnais", + "Louis the German", + "Artaxerxes I of Persia", + "Konrad Adenauer", + "Andrea Mantegna", + "Joseph Fourier", + "Smenkhkare", + "John the Apostle", + "Joseph Louis Lagrange", + "Cai Lun", + "Marcion of Sinope", + "Frederick II of Prussia", + "Gregor Mendel", + "Gaetano Donizetti", + "Pocahontas", + "Johann Heinrich Pestalozzi", + "Maximinus Thrax", + "Cleopatra I of Egypt", + "Lothair I", + "Nicholas Flamel", + "Sun Quan", + "Walter Benjamin", + "Christoph Willibald Gluck", + "Olympias", + "Apollonius of Rhodes", + "Elias Canetti", + "Philip IV of Spain", + "Pope Urban I", + "Pope Leo X", + "Archytas", + "Cao Pi", + "Herbert Spencer", + "Philip III of Spain", + "Vincenzo Bellini", + "Zhu Xi", + "Francis Ford Coppola", + "Galerius", + "Tarquinius Priscus", + "Jean-Paul Marat", + "Idi Amin", + "Mozi", + "Jacques de Molay", + "Antinous", + "Gustav I of Sweden", + "Ariel Sharon", + "Yukio Mishima", + "Theocritus", + "Emperor Meiji" +] \ No newline at end of file diff --git a/cromulant/filter.py b/cromulant/filter.py new file mode 100644 index 0000000..3313b62 --- /dev/null +++ b/cromulant/filter.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QWidget # type: ignore +from PySide6.QtGui import QKeyEvent # type: ignore +from PySide6.QtCore import QTimer # type: ignore + +from .config import Config +from .window import Window + + +class Filter: + debouncer: QTimer + + @staticmethod + def prepare() -> None: + Filter.debouncer = QTimer() + Filter.debouncer.setSingleShot(True) + Filter.debouncer.setInterval(Config.filter_debouncer_delay) + Filter.debouncer.timeout.connect(Filter.do_filter) + + @staticmethod + def get_value() -> str: + return str(Window.filter.text()).lower().strip() + + @staticmethod + def set_value(value: str) -> None: + Window.filter.setText(value) + Filter.do_filter() + + @staticmethod + def clear() -> None: + Window.filter.clear() + Filter.do_filter() + + @staticmethod + def filter(event: QKeyEvent | None = None) -> None: + Filter.debouncer.stop() + Filter.debouncer.start() + + @staticmethod + def do_filter() -> None: + Filter.debouncer.stop() + value = Filter.get_value() + + for i in range(Window.view.count()): + item = Window.view.itemAt(i) + text = Filter.get_text(item) + hide = True + + for txt in text: + if value in txt: + hide = False + break + + if hide: + item.widget().hide() + else: + item.widget().show() + + @staticmethod + def get_text(item: QWidget) -> list[str]: + text = [] + layout = item.widget().layout() + + for i in range(layout.count()): + widget = layout.itemAt(i).widget() + + if not widget: + continue + + name = widget.objectName() + + if name != "view_right": + continue + + layout2 = widget.layout() + + for j in range(layout2.count()): + wid = layout2.itemAt(j).widget() + + if not wid: + continue + + name = wid.objectName() + + if not name: + continue + + if (name == "view_title") or (name == "view_message"): + text.append(wid.text().lower()) + + return text + + @staticmethod + def check() -> None: + value = Filter.get_value() + + if value: + Filter.filter() diff --git a/cromulant/fonts/NotoEmoji-Regular.ttf b/cromulant/fonts/NotoEmoji-Regular.ttf new file mode 100644 index 0000000..0bcb2c0 Binary files /dev/null and b/cromulant/fonts/NotoEmoji-Regular.ttf differ diff --git a/cromulant/fonts/NotoSans-Regular.ttf b/cromulant/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..fa4cff5 Binary files /dev/null and b/cromulant/fonts/NotoSans-Regular.ttf differ diff --git a/cromulant/fonts/NotoSansMono-Regular.ttf b/cromulant/fonts/NotoSansMono-Regular.ttf new file mode 100644 index 0000000..c2bbb5a Binary files /dev/null and b/cromulant/fonts/NotoSansMono-Regular.ttf differ diff --git a/cromulant/game.py b/cromulant/game.py new file mode 100644 index 0000000..791d521 --- /dev/null +++ b/cromulant/game.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +import random +from typing import Any, ClassVar + +from PySide6.QtWidgets import QHBoxLayout # type: ignore +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QFrame +from PySide6.QtWidgets import QMenu +from PySide6.QtWidgets import QDialog +from PySide6.QtWidgets import QGraphicsOpacityEffect +from PySide6.QtGui import QCursor # type: ignore +from PySide6.QtGui import QMouseEvent +from PySide6.QtGui import QPixmap +from PySide6.QtGui import QAction +from PySide6.QtCore import QPropertyAnimation # type: ignore +from PySide6.QtCore import QEasingCurve +from PySide6.QtCore import QSize +from PySide6.QtCore import QTimer +from PySide6.QtCore import Qt + +from .config import Config +from .args import Args +from .utils import Utils +from .ants import Ant +from .ants import Ants +from .window import Window +from .window import RestartDialog +from .settings import Settings + + +class Method: + merge = "merge" + triumph = "triumph" + hit = "hit" + travel = "travel" + think = "think" + words = "words" + + +class Opt: + value = 0 + + def __init__(self, weight: int, method: str) -> None: + self.value = Opt.value + self.weight = weight + self.method = method + + Opt.value += 1 + + +class Opts: + merge = Opt(1, Method.merge) + triumph = Opt(2, Method.triumph) + hit = Opt(2, Method.hit) + travel = Opt(2, Method.travel) + think = Opt(2, Method.think) + words = Opt(4, Method.words) + + @staticmethod + def opts_score() -> list[Opt]: + return [Opts.triumph, Opts.hit] + + @staticmethod + def opts_travel() -> list[Opt]: + return [Opts.travel] + + @staticmethod + def opts_think() -> list[Opt]: + return [Opts.think] + + @staticmethod + def opts_words() -> list[Opt]: + return [Opts.words] + + +class Game: + timer: QTimer + playing_song: bool = False + merge_charge: int = 0 + speed: str = "paused" + animations: ClassVar[list[QPropertyAnimation]] = [] + started: bool = False + + @staticmethod + def prepare() -> None: + Game.timer = QTimer() + Game.timer.timeout.connect(Game.get_status) + + Game.fill() + Game.info() + + if Args.intro: + Game.intro() + + @staticmethod + def update(ant: Ant) -> None: + root = QWidget() + container = QHBoxLayout() + root.setContentsMargins(0, 0, 0, 0) + container.setContentsMargins(0, 0, 0, 0) + + if Args.images: + image_label = Game.get_image(ant) + container.addWidget(image_label) + + right_container = Game.make_right_container(ant) + container.addWidget(right_container) + + container.addSpacing(Config.space_1) + root.setLayout(container) + Game.add_item(root) + + @staticmethod + def message(text: str) -> None: + root = QWidget() + root.setContentsMargins(0, 10, 0, 10) + + container = QHBoxLayout() + container.setAlignment(Qt.AlignCenter) + + left_line = QFrame() + left_line.setFrameShape(QFrame.HLine) + left_line.setFrameShadow(QFrame.Sunken) + left_line.setObjectName("horizontal_line") + left_line.setFixedHeight(2) + Window.expand_2(left_line) + + label = QLabel(text) + label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + right_line = QFrame() + right_line.setFrameShape(QFrame.HLine) + right_line.setFrameShadow(QFrame.Sunken) + right_line.setObjectName("horizontal_line") + right_line.setFixedHeight(2) + Window.expand_2(right_line) + + container.addWidget(left_line) + container.addWidget(label) + container.addWidget(right_line) + + container.setSpacing(Config.space_1 * 2) + root.setLayout(container) + Game.add_item(root) + + @staticmethod + def add_item(item: QWidget) -> None: + from .filter import Filter + + animation: QPropertyAnimation | None = None + + if Game.started and Args.fade: + animation = Game.add_fade(item) + + Window.view.insertWidget(0, item) + + if animation: + animation.start() + + while Window.view.count() > Config.max_updates: + item = Window.view.takeAt(Window.view.count() - 1) + + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + Window.delete_layout(item.layout()) + + Filter.check() + + @staticmethod + def make_right_container(ant: Ant) -> QWidget: + if ant.method == "hatched": + title = "Hatched" + message = f"{ant.name} is born" + elif ant.method == "terminated": + title = "Terminated" + message = f"{ant.name} is gone" + else: + title = ant.name + message = ant.get_status() + + root = QWidget() + root.setObjectName("view_right") + container = QVBoxLayout() + container.setAlignment(Qt.AlignTop) + + title_label = QLabel(title) + title_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + title_label.setStyleSheet("font-weight: bold;") + title_label.setWordWrap(True) + title_label.setObjectName("view_title") + Window.expand(title_label) + + message_label = QLabel(message) + message_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + message_label.setWordWrap(True) + message_label.setObjectName("view_message") + Window.expand(message_label) + + container.addWidget(title_label) + container.addWidget(message_label) + root.setLayout(container) + + return root + + @staticmethod + def get_image(ant: Ant) -> QLabel: + if ant.method == "hatched": + path = Config.hatched_image_path + elif ant.method == "terminated": + path = Config.terminated_image_path + elif ant == Ants.top: + path = Config.top_image_path + else: + path = Config.status_image_path + + if ant.method == "triumph": + color = Config.triumph_color + elif ant.method == "hit": + color = Config.hit_color + else: + color = None + + tooltip = ant.tooltip() + image_label = QLabel() + image_label.setObjectName("view_image") + pixmap = QPixmap(str(path)) + + scaled_pixmap = pixmap.scaled( + Config.image_size, + pixmap.height(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + + image_label.setPixmap(scaled_pixmap) + adjusted_size = scaled_pixmap.size() + QSize(4, 4) + image_label.setFixedSize(adjusted_size) + + if color: + rgb = Utils.get_rgb(color) + + style = f""" + QLabel#view_image {{ + border: 2px solid {rgb}; + }} + """ + + image_label.setStyleSheet(style) + + if tooltip: + image_label.setToolTip(tooltip) + + image_label.mousePressEvent = lambda event: Game.image_action(event, ant) + return image_label + + @staticmethod + def get_status() -> None: + ant = Ants.get_next() + + if not ant: + return + + opts: list[Opt] = [] + + if Settings.score_enabled: + opts.extend(Opts.opts_score()) + + if Settings.travel_enabled: + opts.extend(Opts.opts_travel()) + + if Settings.think_enabled: + opts.extend(Opts.opts_think()) + + if Settings.words_enabled: + opts.extend(Opts.opts_words()) + + if not opts: + return + + values = [opt.value for opt in opts] + weights = [opt.weight for opt in opts] + + if Game.merge_charge < Config.merge_goal: + Game.merge_charge += 1 + + if Settings.merge: + if Game.merge_charge >= Config.merge_goal: + opt = Opts.merge + values.insert(0, opt.value) + weights.insert(0, opt.weight) + + value = random.choices(values, weights=weights, k=1)[0] + + if value == Opts.merge.value: + if Ants.merge(): + Game.merge_charge = 0 + return + + value = Opts.words.value + + status = "" + method = "" + + if value == Opts.triumph.value: + ant.triumph += 1 + method = Opts.triumph.method + + elif value == Opts.hit.value: + ant.hits += 1 + method = Opts.hit.method + + elif value == Opts.travel.value: + status = Utils.random_country([]) + method = Opts.travel.method + + elif value == Opts.think.value: + method = Opts.think.method + n = random.choices([1, 2, 3], weights=[1, 2, 2])[0] + + if n == 1: + status = Utils.random_name([], Ants.get_names()) + elif n == 2: + status = Utils.random_emoji(3) + elif n == 3: + status = Utils.random_word(noun=True, adj=False) + + elif value == Opts.words.value: + method = Opts.words.method + n = random.randint(1, 4) + + if n == 1: + status = Utils.words_1() + elif n == 2: + status = Utils.words_2() + elif n == 3: + status = Utils.words_3() + elif n == 4: + status = Utils.words_4() + + else: + status = "???" + method = "unknown" + + Ants.set_status(ant, status, method) + + @staticmethod + def fill() -> None: + if not len(Ants.ants): + return + + ants = sorted(Ants.ants, key=lambda ant: ant.updated) + + for ant in ants: + Game.update(ant) + + @staticmethod + def start_loop() -> None: + Game.timer.stop() + speed = Settings.speed + + if speed == "fast": + minutes = (Args.fast_seconds or Config.fast_seconds) / 60 + elif speed == "normal": + minutes = Args.normal_minutes or Config.normal_minutes + elif speed == "slow": + minutes = Args.slow_minutes or Config.slow_minutes + else: + Game.speed = "paused" + return + + Game.speed = speed + msecs = minutes * 60 * 1000 + + if msecs < 1000: + msecs = 1000 + + Game.timer.setInterval(msecs) + Game.timer.start() + + @staticmethod + def update_speed() -> None: + speed = Window.speed.currentText().lower() + + if speed == Settings.speed: + return + + Settings.set_speed(speed) + Game.start_loop() + + @staticmethod + def info() -> None: + text = [] + + # Non-breaking space + nb = "\u00a0" + + if not len(Ants.ants): + text.append("Hatch some ants") + else: + text.append(f"Ants:{nb}{len(Ants.ants)}") + top = Ants.top + + if top: + score = top.get_score() + text.append(f"Top:{nb}{top.name} ({score})") + + Window.info.setText(Config.info_separator.join(text)) + + @staticmethod + def toggle_song() -> None: + if Game.playing_song: + Window.stop_audio() + Game.playing_song = False + else: + path = str(Config.song_path) + + def on_stop() -> None: + Game.playing_song = False + + Window.play_audio(path, on_stop) + Game.playing_song = True + + @staticmethod + def restart() -> None: + sizes = ["25", "50", "100", "250"] + defindex = 0 + + for i, opt in enumerate(sizes): + if int(opt) == Config.default_population: + defindex = i + break + + size_opts = [f"{opt} ants" for opt in sizes] + dialog = RestartDialog(size_opts, defindex) + data: dict[str, Any] | None = None + + if dialog.exec() == QDialog.Accepted: + data = dialog.get_data() + + if not data: + return + + size = int(data["size"].split(" ")[0]) + + Game.started = False + Game.timer.stop() + Window.clear_view() + Ants.populate(size) + Window.to_top() + Game.intro() + Game.start_loop() + Game.started = True + + @staticmethod + def update_size() -> None: + pass + + @staticmethod + def image_action(event: QMouseEvent, ant: Ant) -> None: + def is_terminated() -> bool: + return ant.method == "terminated" + + if event.button() == Qt.LeftButton: + if is_terminated(): + return + + Ants.terminate(ant) + Game.start_loop() + elif event.button() == Qt.MiddleButton: + if is_terminated(): + return + + Ants.merge(ant) + Game.start_loop() + else: + Game.toggle_song() + + @staticmethod + def intro() -> None: + title = Config.title + version = Config.version + Game.message(f"Welcome to {title} v{version}") + + @staticmethod + def menu() -> None: + menu = QMenu(Window.root.widget()) + + style = f""" + QMenu::separator {{ + background-color: {Config.alt_border_color}; + }} + """ + + menu.setStyleSheet(style) + menu.setObjectName("main_menu") + update = QAction("Update") + restart = QAction("Restart") + enable_all = QAction("Enable All") + disable_all = QAction("Disable All") + about = QAction("About") + + def make(text: str, enabled: bool) -> QAction: + if enabled: + icon = Config.icon_on + word = "On" + else: + icon = Config.icon_off + word = "Off" + + return QAction(f"{icon} {text} {word}") + + merge = make("Merge", Settings.merge) + score = make("Score", Settings.score_enabled) + travel = make("Travel", Settings.travel_enabled) + think = make("Think", Settings.think_enabled) + words = make("Words", Settings.words_enabled) + + update.triggered.connect(Game.force_update) + restart.triggered.connect(Game.restart) + merge.triggered.connect(Settings.toggle_merge) + score.triggered.connect(Settings.toggle_score_enabled) + travel.triggered.connect(Settings.toggle_travel_enabled) + think.triggered.connect(Settings.toggle_think_enabled) + words.triggered.connect(Settings.toggle_words_enabled) + enable_all.triggered.connect(Settings.enable_all) + disable_all.triggered.connect(Settings.disable_all) + about.triggered.connect(Game.about) + + menu.addAction(update) + menu.addAction(restart) + menu.addSeparator() + menu.addAction(merge) + menu.addAction(score) + menu.addAction(travel) + menu.addAction(think) + menu.addAction(words) + menu.addSeparator() + menu.addAction(enable_all) + menu.addAction(disable_all) + menu.addSeparator() + menu.addAction(about) + menu.exec_(QCursor.pos()) + + @staticmethod + def force_update() -> None: + Game.get_status() + Game.start_loop() + + @staticmethod + def about() -> None: + lines = [ + f"{Config.title} v{Config.version} {Config.ant}", + "Listen to the ants and watch them go.", + "Just run it and leave it open on your screen.", + ] + + Window.alert("\n\n".join(lines)) + + @staticmethod + def slowdown() -> None: + if Game.speed == "slow": + Game.change_speed("paused") + else: + Game.change_speed("slow") + + @staticmethod + def change_speed(speed: str) -> None: + Window.speed.setCurrentText(speed.capitalize()) + + @staticmethod + def filter_top() -> None: + from .filter import Filter + + value = Filter.get_value() + ant = Ants.top + + if not ant: + return + + if value == ant.name.lower(): + Filter.clear() + else: + Filter.set_value(ant.name) + + @staticmethod + def add_fade(item: QWidget) -> QPropertyAnimation: + opacity = QGraphicsOpacityEffect(item) + item.setGraphicsEffect(opacity) + animation = QPropertyAnimation(opacity, b"opacity") + animation.setDuration(Config.fade_duration) + animation.setStartValue(0) + animation.setEndValue(1) + animation.setEasingCurve(QEasingCurve.InOutQuad) + + def on_finish() -> None: + item.setGraphicsEffect(None) + Game.animations.remove(animation) + + animation.finished.connect(on_finish) + Game.animations.append(animation) + return animation diff --git a/cromulant/img/change.jpg b/cromulant/img/change.jpg new file mode 100644 index 0000000..15a31f6 Binary files /dev/null and b/cromulant/img/change.jpg differ diff --git a/cromulant/img/green.sh b/cromulant/img/green.sh new file mode 100755 index 0000000..c64a06a --- /dev/null +++ b/cromulant/img/green.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +convert $1 -channel G -evaluate add 16% +channel -quality 85 -strip $1_green diff --git a/cromulant/img/hatched.jpg b/cromulant/img/hatched.jpg new file mode 100644 index 0000000..f3abf13 Binary files /dev/null and b/cromulant/img/hatched.jpg differ diff --git a/cromulant/img/icon.jpg b/cromulant/img/icon.jpg new file mode 100644 index 0000000..a546efd Binary files /dev/null and b/cromulant/img/icon.jpg differ diff --git a/cromulant/img/logo_1.jpg b/cromulant/img/logo_1.jpg new file mode 100644 index 0000000..3ad23c2 Binary files /dev/null and b/cromulant/img/logo_1.jpg differ diff --git a/cromulant/img/logo_2.jpg b/cromulant/img/logo_2.jpg new file mode 100644 index 0000000..ecb5e4a Binary files /dev/null and b/cromulant/img/logo_2.jpg differ diff --git a/cromulant/img/logo_3.jpg b/cromulant/img/logo_3.jpg new file mode 100644 index 0000000..53ffd1e Binary files /dev/null and b/cromulant/img/logo_3.jpg differ diff --git a/cromulant/img/logo_4.jpg b/cromulant/img/logo_4.jpg new file mode 100644 index 0000000..2d6ac2f Binary files /dev/null and b/cromulant/img/logo_4.jpg differ diff --git a/cromulant/img/red.sh b/cromulant/img/red.sh new file mode 100755 index 0000000..d568948 --- /dev/null +++ b/cromulant/img/red.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +convert $1 -channel R -evaluate add 16% +channel -quality 85 -strip $1_red \ No newline at end of file diff --git a/cromulant/img/status.jpg b/cromulant/img/status.jpg new file mode 100644 index 0000000..35e68e9 Binary files /dev/null and b/cromulant/img/status.jpg differ diff --git a/cromulant/img/terminated.jpg b/cromulant/img/terminated.jpg new file mode 100644 index 0000000..9e8e30d Binary files /dev/null and b/cromulant/img/terminated.jpg differ diff --git a/cromulant/img/top.jpg b/cromulant/img/top.jpg new file mode 100644 index 0000000..7308ee1 Binary files /dev/null and b/cromulant/img/top.jpg differ diff --git a/cromulant/main.py b/cromulant/main.py new file mode 100644 index 0000000..1b439a1 --- /dev/null +++ b/cromulant/main.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os +import sys +import fcntl +import tempfile +from pathlib import Path + +from .config import Config +from .utils import Utils +from .ants import Ants +from .window import Window +from .game import Game +from .settings import Settings +from .filter import Filter +from .args import Args + + +def main() -> None: + Config.prepare() + Args.prepare() + + if Args.argdoc: + Args.make_argdoc() + sys.exit(0) + + program = Config.program + title = Config.title + + pid = f"{program}.pid" + pid_file = Path(tempfile.gettempdir(), pid) + fp = pid_file.open("w", encoding="utf-8") + + try: + fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + Utils.print(f"{title} is already running.") + sys.exit(0) + + # Create singleton + fp.write(str(os.getpid())) + fp.flush() + + Utils.prepare() + Window.prepare() + Ants.prepare() + Settings.prepare() + Filter.prepare() + Game.prepare() + Game.start_loop() + Game.started = True + Window.start() + + +if __name__ == "__main__": + main() diff --git a/cromulant/manifest.json b/cromulant/manifest.json new file mode 100644 index 0000000..ff6470b --- /dev/null +++ b/cromulant/manifest.json @@ -0,0 +1,8 @@ +{ + "version": "4.2.0", + "title": "Cromulant", + "program": "cromulant", + "author": "madprops", + "repo": "github.com/madprops/cromulant", + "description": "Toy game about ants" +} \ No newline at end of file diff --git a/cromulant/ruff.toml b/cromulant/ruff.toml new file mode 100644 index 0000000..d9a89b1 --- /dev/null +++ b/cromulant/ruff.toml @@ -0,0 +1,47 @@ +[lint] + +select = [ + "T", + "Q", + "W", + "B", + "N", + "F", + "FA", + "RET", + "PTH", + "ERA", + "PLW", + "PERF", + "RUF", + "FLY", + "PT", + "PYI", + "PIE", + "ICN", + "UP", + "TRY", + "C4", + "E401", + "E713", + "E721", + "S101", + "S113", + "SIM103", + "SIM114", + "SIM118", + "SIM210", + "PLR5501", + "PLR1711", +] + +ignore = [ + "W292", + "N802", + "N815", +] + +exclude = [ + "pyperclip.py", + "tests.py", +] \ No newline at end of file diff --git a/cromulant/settings.py b/cromulant/settings.py new file mode 100644 index 0000000..922e096 --- /dev/null +++ b/cromulant/settings.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from .args import Args +from .window import Window +from .storage import Storage + + +class Settings: + speed: str + mode: str + merge: bool + + score_enabled: bool + travel_enabled: bool + think_enabled: bool + words_enabled: bool + + @staticmethod + def prepare() -> None: + settings = Storage.get_settings() + changed = False + + if Args.speed: + Settings.speed = Args.speed + changed = True + else: + Settings.speed = settings.get("speed", "normal") + + speed = Settings.speed.capitalize() + Window.speed.setCurrentText(speed) + + Settings.score_enabled = settings.get("score_enabled", True) + Settings.travel_enabled = settings.get("travel_enabled", True) + Settings.think_enabled = settings.get("think_enabled", True) + Settings.words_enabled = settings.get("words_enabled", True) + + Settings.merge = settings.get("merge", True) + + if changed: + Settings.save() + + @staticmethod + def save() -> None: + settings = { + "speed": Settings.speed, + "merge": Settings.merge, + "score_enabled": Settings.score_enabled, + "travel_enabled": Settings.travel_enabled, + "think_enabled": Settings.think_enabled, + "words_enabled": Settings.words_enabled, + } + + Storage.save_settings(settings) + + @staticmethod + def set_speed(speed: str) -> None: + Settings.speed = speed + Settings.save() + + @staticmethod + def toggle_merge() -> None: + Settings.merge = not Settings.merge + Settings.save() + + @staticmethod + def toggle_score_enabled() -> None: + Settings.score_enabled = not Settings.score_enabled + Settings.save() + + @staticmethod + def toggle_travel_enabled() -> None: + Settings.travel_enabled = not Settings.travel_enabled + Settings.save() + + @staticmethod + def toggle_think_enabled() -> None: + Settings.think_enabled = not Settings.think_enabled + Settings.save() + + @staticmethod + def toggle_words_enabled() -> None: + Settings.words_enabled = not Settings.words_enabled + Settings.save() + + @staticmethod + def enable_all() -> None: + Settings.merge = True + Settings.score_enabled = True + Settings.travel_enabled = True + Settings.think_enabled = True + Settings.words_enabled = True + Settings.save() + + @staticmethod + def disable_all() -> None: + Settings.merge = False + Settings.score_enabled = False + Settings.travel_enabled = False + Settings.think_enabled = False + Settings.words_enabled = False + Settings.save() diff --git a/cromulant/storage.py b/cromulant/storage.py new file mode 100644 index 0000000..0d26623 --- /dev/null +++ b/cromulant/storage.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from pathlib import Path + +from .config import Config + +if TYPE_CHECKING: + from .ants import Ant + +from .args import Args +from .utils import Utils + + +class Storage: + @staticmethod + def get_names_path() -> Path: + path = Config.names_json + + if Args.names: + if Args.names.exists(): + path = Args.names + + return path + + @staticmethod + def get_ants_path() -> Path: + path = Config.ants_json + + if Args.ants: + if Args.ants.exists(): + path = Args.ants + + return path + + @staticmethod + def get_ants() -> Any: + try: + path = Storage.get_ants_path() + + with path.open() as file: + return json.load(file) + except Exception as e: + Utils.print(str(e)) + return [] + + @staticmethod + def save_ants(ants: list[Ant]) -> None: + objs = [ant.to_dict() for ant in ants] + path = Storage.get_ants_path() + + with path.open("w") as file: + json.dump(objs, file) + + @staticmethod + def get_names() -> Any: + path = Storage.get_names_path() + + with path.open() as file: + return json.load(file) + + @staticmethod + def get_settings() -> Any: + try: + with Config.settings_json.open() as file: + return json.load(file) + except Exception as e: + Utils.print(str(e)) + return {} + + @staticmethod + def save_settings(settings: dict[str, Any]) -> None: + with Config.settings_json.open("w") as file: + json.dump(settings, file) + + @staticmethod + def get_countries() -> Any: + with Config.countries_json.open() as file: + return json.load(file) + + @staticmethod + def get_manifest() -> Any: + with Config.manifest_path.open() as file: + return json.load(file) + + @staticmethod + def save_arguments(text: str) -> None: + with Config.arguments_path.open("w") as file: + file.write(text) diff --git a/cromulant/utils.py b/cromulant/utils.py new file mode 100644 index 0000000..20d59d4 --- /dev/null +++ b/cromulant/utils.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import random +import colorsys +import time +from datetime import datetime +from typing import ClassVar + +from wonderwords import RandomWord, RandomSentence # type: ignore +from fontTools.ttLib import TTFont # type: ignore + +from .config import Config + + +class Utils: + names: ClassVar[list[str]] = [] + countries: ClassVar[list[str]] = [] + rand_word: RandomWord + rand_sentence: RandomSentence + vowels = "aeiou" + consonants = "bcdfghjklmnpqrstvwxyz" + + @staticmethod + def prepare() -> None: + from .storage import Storage + + Utils.names = Storage.get_names() + Utils.countries = Storage.get_countries() + Utils.rand_word = RandomWord() + Utils.rand_sentence = RandomSentence() + + @staticmethod + def now() -> int: + return int(time.time()) + + @staticmethod + def singular_or_plural(num: float, singular: str, plural: str) -> str: + if num == 1: + return singular + + return plural + + @staticmethod + def time_ago(start_time: float, end_time: float) -> str: + diff = end_time - start_time + seconds = int(diff) + + if seconds < 60: + word = Utils.singular_or_plural(seconds, "second", "seconds") + return f"{seconds} {word} ago" + + minutes = seconds // 60 + + if minutes < 60: + word = Utils.singular_or_plural(minutes, "minute", "minutes") + return f"{minutes} {word} ago" + + hours = minutes / 60 + + if hours < 24: + word = Utils.singular_or_plural(hours, "hour", "hours") + return f"{hours:.1f} {word} ago" + + days = hours / 24 + + if days < 30: + word = Utils.singular_or_plural(days, "day", "days") + return f"{days:.1f} {word} ago" + + months = days / 30 + + if months < 12: + word = Utils.singular_or_plural(months, "month", "months") + return f"{months:.1f} {word} ago" + + years = months / 12 + word = Utils.singular_or_plural(years, "year", "years") + return f"{years:.1f} {word} ago" + + @staticmethod + def print(text: str) -> None: + print(text) # noqa: T201 + + @staticmethod + def random_color(seed: str) -> tuple[int, int, int]: + seed_int = hash(seed) + random.seed(seed_int) + + h, s, l = ( + random.random(), + 0.5 + random.random() / 2.0, + 0.4 + random.random() / 5.0, + ) + + r, g, b = (int(256 * i) for i in colorsys.hls_to_rgb(h, l, s)) + return r, g, b + + @staticmethod + def random_name(ignore: list[str], include: list[str] | None = None) -> str: + names = Utils.names + + if include: + for name in include: + if name not in names: + names.append(name) + + filtered = [name for name in Utils.names if name not in ignore] + + if not filtered: + return Utils.make_name() + + return random.choice(filtered) + + @staticmethod + def get_rgb(color: tuple[int, int, int]) -> str: + return f"rgb{color}" + + @staticmethod + def random_character(font_path: str, num: int) -> str: + font = TTFont(font_path) + cmap = font["cmap"] + unicode_map = cmap.getBestCmap() + characters = [chr(code_point) for code_point in unicode_map] + + for _ in range(10): # Try up to 10 times + selected = random.sample(characters, num) + + if all((char.isprintable() and not char.isspace()) for char in selected): + return " ".join(selected) + + return "" + + @staticmethod + def random_emoji(num: int) -> str: + return Utils.random_character(str(Config.emoji_font_path), num) + + @staticmethod + def to_date(timestamp: float) -> str: + dt_object = datetime.fromtimestamp(timestamp) + hour = dt_object.strftime("%I").lstrip("0") + return dt_object.strftime(f"%b %d %Y - {hour}:%M %p") + + @staticmethod + def get_timeword(minutes: float) -> str: + if minutes < 1: + seconds = round(minutes * 60) + + if seconds == 1: + return "1 second" + + if seconds < 60: + return f"{seconds} seconds" + + if minutes == 1: + return "1 minute" + + return f"{round(minutes)} minutes" + + @staticmethod + def random_country(ignore: list[str]) -> str: + filtered = [country for country in Utils.countries if country not in ignore] + return random.choice(filtered) + + @staticmethod + def random_word(noun: bool = True, adj: bool = True) -> str: + opts = [] + + if noun: + opts.append("noun") + + if adj: + opts.append("adjective") + + if not len(opts): + return "" + + word = Utils.rand_word.word(include_parts_of_speech=opts, word_max_length=8) + return str(word) + + @staticmethod + def random_words(num: int = 1, noun: bool = True, adj: bool = True) -> list[str]: + return [Utils.random_word(noun=noun) for _ in range(num)] + + @staticmethod + def capitalize(word: str) -> str: + return word[0].upper() + word[1:] + + @staticmethod + def words_1() -> str: + return str(Utils.rand_sentence.simple_sentence()) + + @staticmethod + def words_2() -> str: + return str(Utils.rand_sentence.bare_bone_sentence()) + + @staticmethod + def words_3() -> str: + return str(Utils.rand_sentence.bare_bone_with_adjective()) + + @staticmethod + def words_4() -> str: + return str(Utils.rand_sentence.sentence()) + + @staticmethod + def make_word() -> str: + name = "" + name += random.choice(Utils.consonants) + name += random.choice(Utils.vowels) + name += random.choice(Utils.consonants) + name += random.choice(Utils.vowels) + return name + + @staticmethod + def make_words(num: int = 1) -> list[str]: + return [Utils.make_word() for _ in range(num)] + + @staticmethod + def make_name() -> str: + words = Utils.make_words(2) + words = [word.capitalize() for word in words] + return " ".join(words) diff --git a/cromulant/window.py b/cromulant/window.py new file mode 100644 index 0000000..8543f9b --- /dev/null +++ b/cromulant/window.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +from typing import Any +from collections.abc import Callable +import signal + +from PySide6.QtWidgets import QApplication # type: ignore +from PySide6.QtWidgets import QDialog +from PySide6.QtWidgets import QMainWindow +from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QGraphicsScene +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QScrollArea +from PySide6.QtWidgets import QComboBox +from PySide6.QtWidgets import QLayout +from PySide6.QtWidgets import QSizePolicy +from PySide6.QtWidgets import QMessageBox +from PySide6.QtWidgets import QLineEdit +from PySide6.QtGui import QShortcut # type: ignore +from PySide6.QtGui import QKeySequence +from PySide6.QtGui import QFontDatabase +from PySide6.QtGui import QIcon +from PySide6.QtGui import QKeyEvent +from PySide6.QtGui import QMouseEvent +from PySide6.QtCore import Qt # type: ignore +from PySide6.QtCore import QUrl +from PySide6.QtCore import Signal +from PySide6.QtMultimedia import QMediaPlayer # type: ignore +from PySide6.QtMultimedia import QAudioOutput + +from .config import Config +from .args import Args +from .utils import Utils + + +class SpecialButton(QPushButton): # type: ignore + middleClicked = Signal() + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + def mousePressEvent(self, e: QMouseEvent) -> None: + if e.button() == Qt.MiddleButton: + self.middleClicked.emit() + else: + super().mousePressEvent(e) + + +class SpecialComboBox(QComboBox): # type: ignore + middleClicked = Signal() + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + def mousePressEvent(self, e: QMouseEvent) -> None: + if e.button() == Qt.MiddleButton: + self.middleClicked.emit() + else: + super().mousePressEvent(e) + + +class FilterLineEdit(QLineEdit): # type: ignore + def keyPressEvent(self, e: QKeyEvent) -> None: + if e.key() == Qt.Key_Escape: + self.clear() + else: + super().keyPressEvent(e) + + +class RestartDialog(QDialog): # type: ignore + def __init__(self, sizes: list[str], defindex: int) -> None: + super().__init__() + self.setWindowTitle("Select Option") + self.setFixedSize(300, 150) + + self.layout = QVBoxLayout() + + self.label = QLabel("Size of the population") + self.layout.addWidget(self.label) + + self.size_combo = QComboBox() + self.size_combo.addItems(sizes) + self.size_combo.setCurrentIndex(defindex) + self.layout.addWidget(self.size_combo) + + self.button_layout = QHBoxLayout() + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + self.button_layout.addWidget(self.cancel_button) + + self.ok_button = QPushButton("OK") + self.ok_button.clicked.connect(self.accept) + self.ok_button.setDefault(True) + self.button_layout.addWidget(self.ok_button) + + self.layout.addLayout(self.button_layout) + self.setLayout(self.layout) + self.setWindowFlags(Qt.Popup) + + def get_data(self) -> dict[str, Any]: + return { + "size": str(self.size_combo.currentText()), + } + + +class Window: + app: QApplication + window: QMainWindow + root: QVBoxLayout + view: QVBoxLayout + view_scene: QGraphicsScene + speed: QComboBox + scroll_area: QScrollArea + info: QPushButton + font: str + emoji_font: str + mono_font: str + player: QMediaPlayer + audio: QAudioOutput + filter: QLineEdit + + @staticmethod + def prepare() -> None: + Window.make() + Window.add_buttons() + Window.add_view() + Window.add_footer() + Window.setup_keyboard() + + @staticmethod + def make() -> None: + Window.app = QApplication([]) + program = Args.program or Config.program + Window.app.setApplicationName(program) + + Window.window = QMainWindow() + title = Args.title or Config.title + Window.window.setWindowTitle(title) + width = Args.width or Config.width + height = Args.height or Config.height + Window.window.resize(width, height) + + central_widget = QWidget() + Window.root = QVBoxLayout() + central_widget.setLayout(Window.root) + Window.root.setAlignment(Qt.AlignTop) + Window.window.setCentralWidget(central_widget) + Window.window.setWindowIcon(QIcon(str(Config.icon_path))) + Window.root.setContentsMargins(0, 0, 0, 0) + Window.set_style() + + @staticmethod + def set_style() -> None: + font_id = QFontDatabase.addApplicationFont(str(Config.font_path)) + emoji_font_id = QFontDatabase.addApplicationFont(str(Config.emoji_font_path)) + mono_font_id = QFontDatabase.addApplicationFont(str(Config.mono_font_path)) + + if font_id != -1: + Window.font = QFontDatabase.applicationFontFamilies(font_id)[0] + + if emoji_font_id != -1: + Window.emoji_font = QFontDatabase.applicationFontFamilies(emoji_font_id)[0] + + if mono_font_id != -1: + Window.mono_font = QFontDatabase.applicationFontFamilies(mono_font_id)[0] + + style = f""" + + QWidget {{ + background-color: {Config.background_color}; + color: {Config.text_color}; + font-size: {Config.font_size}px; + }} + + QMenu {{ + background-color: {Config.alt_background_color}; + color: {Config.alt_text_color}; + border: 2px solid {Config.alt_border_color}; + }} + + QMenu::item:selected {{ + background-color: {Config.alt_hover_background_color}; + color: {Config.alt_hover_text_color}; + }} + + QDialog {{ + background-color: {Config.alt_background_color}; + color: {Config.alt_text_color}; + border: 2px solid {Config.alt_border_color}; + }} + + QDialog QLabel {{ + background-color: {Config.alt_background_color}; + color: {Config.alt_text_color}; + }} + + QDialog QPushButton {{ + background-color: {Config.alt_background_color}; + color: {Config.alt_text_color}; + }} + + QDialog QPushButton:hover {{ + background-color: {Config.message_box_button_hover_background_color}; + color: {Config.message_box_button_hover_text_color}; + }} + + QComboBox {{ + selection-background-color: {Config.alt_hover_background_color}; + selection-color: {Config.alt_hover_text_color}; + }} + + QComboBox QAbstractItemView {{ + background-color: {Config.alt_background_color}; + color: {Config.alt_text_color}; + border: 2px solid {Config.alt_border_color}; + padding: 6px; + }} + + QScrollBar:vertical {{ + border: 0px solid transparent; + background: {Config.background_color}; + width: 15px; + margin: 0px 0px 0px 0px; + }} + + QScrollBar::handle:vertical {{ + background: {Config.scrollbar_handle_color}; + min-height: 20px; + }} + + QScrollBar::add-line:vertical {{ + border: none; + background: none; + }} + + QScrollBar::sub-line:vertical {{ + border: none; + background: none; + }} + + QLineEdit {{ + background-color: {Config.input_background_color}; + color: {Config.input_text_color}; + border: 1px solid {Config.input_border_color}; + }} + + QFrame#horizontal_line {{ + background-color: white; + color: white; + }} + + QLabel#menu_label:hover {{ + background-color: {Config.alt_hover_background_color}; + }} + + """.strip() + + Window.app.setStyleSheet(style) + + if Args.mono: + Window.app.setFont(Window.mono_font) + else: + Window.app.setFont(Window.font) + + @staticmethod + def add_buttons() -> None: + from .game import Game + from .filter import Filter + + root = QWidget() + container = QHBoxLayout() + + btn_menu = SpecialButton("Menu") + btn_menu.setToolTip("The main menu\nMiddle Click: Update") + btn_menu.clicked.connect(Game.menu) + btn_menu.middleClicked.connect(Game.force_update) + + Window.speed = SpecialComboBox() + tooltip = "The speed of the updates\n" + + fast = (Args.fast_seconds or Config.fast_seconds) / 60 + tooltip += f"Fast: {Utils.get_timeword(fast)}\n" + + normal = Args.normal_minutes or Config.normal_minutes + tooltip += f"Normal: {Utils.get_timeword(normal)}\n" + + slow = Args.slow_minutes or Config.slow_minutes + tooltip += f"Slow: {Utils.get_timeword(slow)}\n" + + tooltip += "Middle Click: Slow" + Window.speed.setToolTip(tooltip) + Window.speed.addItems(["Fast", "Normal", "Slow", "Paused"]) + Window.speed.setCurrentIndex(1) + Window.speed.currentIndexChanged.connect(Game.update_speed) + Window.speed.middleClicked.connect(Game.slowdown) + + Window.filter = FilterLineEdit() + Window.filter.setPlaceholderText("Filter") + Window.filter.mousePressEvent = lambda e: Window.to_top() + Window.filter.keyReleaseEvent = lambda e: Filter.filter(e) + + container.addWidget(btn_menu, 1) + container.addWidget(Window.speed, 1) + container.addWidget(Window.filter, 1) + + root.setLayout(container) + + if not Args.header: + root.setVisible(False) + + Window.root.addWidget(root) + + @staticmethod + def add_view() -> None: + Window.scroll_area = QScrollArea() + Window.scroll_area.setWidgetResizable(True) + + container = QWidget() + parent = QVBoxLayout(container) + Window.view = QVBoxLayout() + parent.addLayout(Window.view) + + Window.view.setAlignment(Qt.AlignTop) + Window.scroll_area.setWidget(container) + Window.root.addWidget(Window.scroll_area) + + @staticmethod + def start() -> None: + signal.signal(signal.SIGINT, signal.SIG_DFL) + Window.window.show() + Window.app.exec() + + @staticmethod + def close() -> None: + Window.app.quit() + + @staticmethod + def delete_layout(layout: QLayout) -> None: + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + Window.delete_layout(item.layout()) + + layout.deleteLater() + + @staticmethod + def expand(widget: QWidget) -> None: + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + @staticmethod + def expand_2(widget: QWidget) -> None: + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + @staticmethod + def clear_view() -> None: + while Window.view.count(): + item = Window.view.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + Window.delete_layout(item.layout()) + + @staticmethod + def to_top() -> None: + Window.scroll_area.verticalScrollBar().setValue(0) + + @staticmethod + def to_bottom() -> None: + Window.scroll_area.verticalScrollBar().setValue( + Window.scroll_area.verticalScrollBar().maximum() + ) + + @staticmethod + def toggle_scroll() -> None: + maxim = Window.scroll_area.verticalScrollBar().maximum() + + if Window.scroll_area.verticalScrollBar().value() == maxim: + Window.to_top() + else: + Window.to_bottom() + + @staticmethod + def add_footer() -> None: + from .game import Game + + root = QWidget() + root.setContentsMargins(0, 0, 0, 0) + container = QHBoxLayout() + Window.info = SpecialButton("---") + + Window.info.setToolTip( + "Click to scroll to the bottom or top\nMiddle Click: Filter Top" + ) + + Window.info.clicked.connect(Window.toggle_scroll) + Window.info.middleClicked.connect(Game.filter_top) + Window.info.setMinimumSize(35, 35) + container.addWidget(Window.info) + root.setLayout(container) + + if not Args.footer: + root.setVisible(False) + + Window.root.addWidget(root) + + @staticmethod + def play_audio(path: str, on_stop: Callable[..., Any] | None = None) -> None: + Window.player = QMediaPlayer() + Window.audio = QAudioOutput() + Window.player.setAudioOutput(Window.audio) + Window.player.setSource(QUrl.fromLocalFile(path)) + Window.audio.setVolume(100) + + def handle_state_change(state: QMediaPlayer.State) -> None: + if state == QMediaPlayer.StoppedState: + if on_stop: + on_stop() + + Window.player.playbackStateChanged.connect(handle_state_change) + Window.player.play() + + @staticmethod + def stop_audio() -> None: + Window.player.stop() + + @staticmethod + def alert(message: str) -> None: + msg_box = QMessageBox() + msg_box.setWindowFlags(Qt.Popup) + msg_box.setIcon(QMessageBox.Information) + msg_box.setText(message) + msg_box.setWindowTitle("Information") + msg_box.setStandardButtons(QMessageBox.Ok) + msg_box.exec() + + @staticmethod + def setup_keyboard() -> None: + on_enter = QShortcut(QKeySequence(Qt.Key_Return), Window.window) + on_enter.activated.connect(Window.toggle_scroll) diff --git a/more.md b/more.md new file mode 100644 index 0000000..e152d75 --- /dev/null +++ b/more.md @@ -0,0 +1,25 @@ +## Propaganda + +![](cromulant/img/logo_1.jpg) + +![](cromulant/img/logo_2.jpg) + +![](cromulant/img/logo_3.jpg) + +![](cromulant/img/logo_4.jpg) + +## Assets + +![](cromulant/img/status.jpg) + +![](cromulant/img/hatched.jpg) + +![](cromulant/img/terminated.jpg) + +![](cromulant/img/change.jpg) + +![](cromulant/img/top.jpg) + +## Soundtrack + +[March of The Cyber Ants](cromulant/audio/March%20of%20the%20Cyber%20Ants.mp3) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4014e22 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PySide6 == 6.7.2 +appdirs == 1.4.4 +wonderwords == 2.2.0 +fonttools == 4.53.1 +pre-commit == 3.7.1 \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..e55dcd9 --- /dev/null +++ b/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +root="$(dirname "$(readlink -f "$0")")" +cd "$root" + +venv/bin/python -m cromulant.main "$@" \ No newline at end of file diff --git a/screenshots.md b/screenshots.md new file mode 100644 index 0000000..0824336 --- /dev/null +++ b/screenshots.md @@ -0,0 +1,9 @@ +![](https://i.imgur.com/5yIX4hl.jpeg) + +![](https://i.imgur.com/e48HxaC.jpeg) + +![](https://i.imgur.com/Y66isBl.jpeg) + +![](https://i.imgur.com/qZnnxoB.jpeg) + +![](https://i.imgur.com/7O5BP5K.jpeg) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bfe01c7 --- /dev/null +++ b/setup.py @@ -0,0 +1,69 @@ +from setuptools import setup, find_packages +from pathlib import Path +import shutil +import json +import platform + +with open("cromulant/manifest.json", "r") as file: + manifest = json.load(file) + +title = manifest["title"] +program = manifest["program"] +version = manifest["version"] + + +def _post_install(): + system = platform.system() + + if system == "Linux": + try: + _copy_icon_file() + _create_desktop_file() + except Exception as e: + print(f"Error during post install: {e}") + + +def _copy_icon_file(): + source = Path(f"{program}/img/icon_1.jpg").expanduser().resolve() + destination = Path(f"~/.local/share/icons/{program}.png").expanduser().resolve() + shutil.copy2(source, destination) + + +def _create_desktop_file(): + content = f"""[Desktop Entry] +Version=1.0 +Name={title} +Exec={Path(f"~/.local/bin/{program}").expanduser().resolve()} +Icon={Path(f"~/.local/share/icons/{program}.png").expanduser().resolve()} +Terminal=false +Type=Application +Categories=Utility; +""" + + file_path = Path(f"~/.local/share/applications/{program}.desktop").expanduser().resolve() + + with open(file_path, 'w') as f: + f.write(content) + + +with open("requirements.txt") as f: + requirements = f.read().splitlines() + +package_data = {} +package_data[program] = ["**/*.png", "**/*.jpg", "**/*.json", "**/*.mp3", "**/*.ttf"] + +setup( + name=title, + version=version, + install_requires=requirements, + packages=find_packages(where="."), + package_dir={"": "."}, + package_data=package_data, + entry_points={ + "console_scripts": [ + f"{program}={program}.main:main", + ], + }, +) + +_post_install() \ No newline at end of file diff --git a/utils/tag.py b/utils/tag.py new file mode 100755 index 0000000..3acd77c --- /dev/null +++ b/utils/tag.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +# This is used to create a tag in the git repo +# You probably don't want to run this + +# pacman: python-gitpython +import os +import git +import json +from pathlib import Path + +here = Path(__file__).resolve() +parent = here.parent.parent +os.chdir(parent) + +with open("cromulant/manifest.json") as f: + manifest = json.loads(f.read()) + +version = manifest["version"] +repo = git.Repo(".") +repo.create_tag(version) +repo.remotes.origin.push(version) +print(f"Created tag: {version}")