diff --git a/.github/workflows/gen-docs.yml b/.github/workflows/gen-docs.yml index a8f7ee6b..8af5161b 100644 --- a/.github/workflows/gen-docs.yml +++ b/.github/workflows/gen-docs.yml @@ -29,7 +29,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: doc - args: --features raylib/nobuild + args: --features raylib/nobuild -p game_logic --no-deps --document-private-items - name: Build mdBook run: mdbook build diff --git a/.gitignore b/.gitignore index 32db6399..8c90d182 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,158 @@ Cargo.lock *.pdb # MdBook generated files -/book \ No newline at end of file +/book +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +/dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +/screenshot*.png \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 26d1811d..1754528f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,8 @@ "git.detectSubmodules": false, "cSpell.words": [ "msaa", + "raylib", + "repr", "vsync" ], "files.watcherExclude": { @@ -10,5 +12,13 @@ "**/node_modules/*/**": true, "**/.hg/store/**": true, "**/target/**": true, + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/__pycache__": true, } } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index fd3b5110..388ab5f5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,6 +17,32 @@ "$rustc" ], "label": "Launch Game" + }, + { + "type": "cargo", + "command": "run", + "args": [ + "--", + "--verbose" + ], + "problemMatcher": [ + "$rustc" + ], + "label": "Launch Game [DEBUG LOGS]" + }, + { + "type": "cargo", + "command": "doc", + "args": [ + "-p", + "game_logic", + "--no-deps", + "--document-private-items" + ], + "problemMatcher": [ + "$rustc" + ], + "label": "Regenerate RustDoc" } ] } \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index bf862527..9cbcb198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["./game/game_logic", "./game/desktop_wrapper"] exclude = [ "./third_party/raylib-rs/raylib", - "./third_party/raylib-rs/raylib-sys", + "./third_party/raylib-rs/raylib-sys" ] [profile.release] diff --git a/README.md b/README.md index d33fb03b..1c680199 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # Ludum Dare 50: *unnamed game* [![Build Full Release](https://github.com/Ewpratten/ludum-dare-50/actions/workflows/build.yml/badge.svg)](https://github.com/Ewpratten/ludum-dare-50/actions/workflows/build.yml) +## Navigating this repository + +- `/game`: The game resource directory + - `/game/desktop_wrapper`: A desktop launcher for the game + - `/game/game_logic`: The game code + - `/game/dist`: The assets for the game (these are packaged with the final executable) +- `/assets`: Various asset files sorted by user (these are **not** packaged with the game) +- `/docs`: Documentation for the game +- `/automation`: Tools to make our lives easier. Written in various languages +- `/third_party`: Custom forks of third party libraries + ## Cloning **IMPORTANT:** This project makes use of recursive submodules. Make sure to pull them via GitKracken, or with the following command: diff --git a/assets/ewpratten/.gitignore b/assets/ewpratten/.gitignore new file mode 100644 index 00000000..f8e685ce --- /dev/null +++ b/assets/ewpratten/.gitignore @@ -0,0 +1 @@ +**/*.jpg~ \ No newline at end of file diff --git a/assets/ewpratten/chr_testFox/preview-01.jpg b/assets/ewpratten/chr_testFox/preview-01.jpg new file mode 100644 index 00000000..5b1a5cb9 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-01.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-02.jpg b/assets/ewpratten/chr_testFox/preview-02.jpg new file mode 100644 index 00000000..ea31d94f Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-02.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-03.jpg b/assets/ewpratten/chr_testFox/preview-03.jpg new file mode 100644 index 00000000..efdb232b Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-03.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-04.jpg b/assets/ewpratten/chr_testFox/preview-04.jpg new file mode 100644 index 00000000..9f698900 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-04.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-05.jpg b/assets/ewpratten/chr_testFox/preview-05.jpg new file mode 100644 index 00000000..48de7ffc Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-05.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-06.jpg b/assets/ewpratten/chr_testFox/preview-06.jpg new file mode 100644 index 00000000..b436dd26 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-06.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-07.jpg b/assets/ewpratten/chr_testFox/preview-07.jpg new file mode 100644 index 00000000..ec2b3e99 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-07.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-08.jpg b/assets/ewpratten/chr_testFox/preview-08.jpg new file mode 100644 index 00000000..96524909 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-08.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-09.jpg b/assets/ewpratten/chr_testFox/preview-09.jpg new file mode 100644 index 00000000..f15d68e3 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-09.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-10.jpg b/assets/ewpratten/chr_testFox/preview-10.jpg new file mode 100644 index 00000000..6e90782c Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-10.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-11.jpg b/assets/ewpratten/chr_testFox/preview-11.jpg new file mode 100644 index 00000000..94c290bf Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-11.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-12.jpg b/assets/ewpratten/chr_testFox/preview-12.jpg new file mode 100644 index 00000000..51601a61 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-12.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-13.jpg b/assets/ewpratten/chr_testFox/preview-13.jpg new file mode 100644 index 00000000..3ff4edb0 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-13.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-14.jpg b/assets/ewpratten/chr_testFox/preview-14.jpg new file mode 100644 index 00000000..cb1f3954 Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-14.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-15.jpg b/assets/ewpratten/chr_testFox/preview-15.jpg new file mode 100644 index 00000000..b79bb1df Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-15.jpg differ diff --git a/assets/ewpratten/chr_testFox/preview-16.jpg b/assets/ewpratten/chr_testFox/preview-16.jpg new file mode 100644 index 00000000..b8337fbf Binary files /dev/null and b/assets/ewpratten/chr_testFox/preview-16.jpg differ diff --git a/automation/anim_stitcher/__main__.py b/automation/anim_stitcher/__main__.py new file mode 100644 index 00000000..e06e4e8d --- /dev/null +++ b/automation/anim_stitcher/__main__.py @@ -0,0 +1,27 @@ +import argparse +import sys +import logging +from qt_common.qt_app_wrapper import QtAppWrapper +from . import ui + +def main() -> int: + # Handle program arguments + ap = argparse.ArgumentParser( + prog='anim_stitcher', description='A tool for stitching PNG sequences into sprite sheets') + + args = ap.parse_args() + + # Setup logging + logging.basicConfig(level=logging.DEBUG) + + # Run the application + with QtAppWrapper(): + # Create and show the window + w = ui.AnimStitcherWindow() + w.show() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/automation/anim_stitcher/stitcher.py b/automation/anim_stitcher/stitcher.py new file mode 100644 index 00000000..9f92d04f --- /dev/null +++ b/automation/anim_stitcher/stitcher.py @@ -0,0 +1,95 @@ +"""This file contains the actual stitcher logic.""" + +import os +from PIL import Image +import json +import logging +import time +import getpass +from typing import List +from project_root import get_project_root +logger = logging.getLogger(__name__) + + +def check_sprite_exists(sprite_type: str, sprite_name: str) -> bool: + """Checks if a sprite directory exists for the given sprite type and name. + + Args: + sprite_type (str): Sprite type (short name) + sprite_name (str): Sprite name + + Returns: + bool: Does it exist? + """ + + # Get the project root + project_root = get_project_root() + logger.debug(f"Project root: {project_root}") + + # Build the path the sprite should exist in + sprite_path = os.path.join( + project_root, "game", "dist", "assets", "anm", sprite_type, f"{sprite_type}_{sprite_name}") + + return os.path.isdir(sprite_path) + + +def stitch_images_and_write_to_disk(sprite_type: str, sprite_name: str, images: List[str], quantize: bool, fps: float, reverse_order: bool) -> None: + + # Handle reverse order + if reverse_order: + images = images[::-1] + + # Load all the images + images_to_stitch = [] + for image_path in images: + images_to_stitch.append(Image.open(image_path)) + + # Collect the total width and maximum height of the images while building a list of the sizes + total_width = 0 + max_height = 0 + for image in images_to_stitch: + total_width += image.size[0] + max_height = max(max_height, image.size[1]) + + # Create a new image with the total width and maximum height + new_image = Image.new("RGBA", (total_width, max_height)) + + # Paste each image into the new image + x_offset = 0 + for image in images_to_stitch: + new_image.paste(image, (x_offset, 0)) + x_offset += image.size[0] + + # Save the new image + project_root = get_project_root() + logger.debug(f"Project root: {project_root}") + if quantize: + new_image = new_image.quantize(method=2) + new_image.save(os.path.join(project_root, "game", "dist", "assets", "anm", sprite_type, + f"{sprite_type}_{sprite_name}", f"{sprite_type}_{sprite_name}.png")) + + # Build some JSON metadata + metadata = { + "sheet_height": max_height, + "sheet_width": total_width, + "published_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), + "published_by": getpass.getuser(), + "fps": fps, + "frames": [] + } + + # Add the metadata for each image + x_offset = 0 + for i in range(len(images_to_stitch)): + metadata["frames"].append({ + "x": x_offset, + "y": 0, + "width": image.size[0], + "height": image.size[1] + }) + x_offset += image.size[0] + + # Write the metadata to disk + with open(os.path.join(project_root, "game", "dist", "assets", "anm", sprite_type, + f"{sprite_type}_{sprite_name}", f"{sprite_type}_{sprite_name}.anim_meta.json"), "w") as f: + json.dump(metadata, f, indent=4) diff --git a/automation/anim_stitcher/ui.py b/automation/anim_stitcher/ui.py new file mode 100644 index 00000000..1d9b8884 --- /dev/null +++ b/automation/anim_stitcher/ui.py @@ -0,0 +1,210 @@ +"""anim_stitcher GUI""" + +from PySide2 import QtWidgets +from PySide2.QtCore import Qt +import pkgutil +from qt_common import qt_window_center, qt_lines, qt_dialog_style +import sprite_types +import re +import os +from . import stitcher +from project_root import get_project_root + +import logging +logger = logging.getLogger("anim_stitcher.ui") + +SPRITE_NAMING_VALIDATION = r"^[a-z][a-zA-Z\d]+$" + + +class AnimStitcherWindow(QtWidgets.QWidget): + + selected_files = None + + def __init__(self): + super(AnimStitcherWindow, self).__init__() + + # Configure the window + self.setWindowFlags( + self.windowFlags() ^ Qt.Window) + self.setWindowTitle("Anim Stitcher") + self.resize(280, 200) + qt_window_center.center_window(self) + + # Set the root of the application to be a vertical list + self.setLayout(QtWidgets.QVBoxLayout()) + + # Load the stylesheet for this app + self.setStyleSheet(qt_dialog_style.STYLESHEET) + + # Configure the title at the top of the window + self.label = QtWidgets.QLabel("Anim Stitcher") + self.label.setProperty('labelClass', 'label-title') + self.layout().addWidget(self.label) + self.description = QtWidgets.QLabel( + "Stitch PNG sequences into a sprite sheet") + self.description.setProperty('labelClass', 'label-description') + self.layout().addWidget(self.description) + self.layout().addWidget(qt_lines.QHLine()) + + # Add an import button + self.import_button = QtWidgets.QPushButton("Select PNGs") + self.import_button.clicked.connect(self.load_png_dialog) + self.layout().addWidget(self.import_button) + self.layout().addWidget(qt_lines.QHLine()) + + # Add a selection option for the sprite type + known_sprite_types = sprite_types.get_known_sprite_types().values() + self.sprite_type_layout = QtWidgets.QHBoxLayout() + self.sprite_type_label = QtWidgets.QLabel("Sprite Type") + self.sprite_type_layout.addWidget(self.sprite_type_label) + self.sprite_type_dropdown = QtWidgets.QComboBox() + for ty in known_sprite_types: + self.sprite_type_dropdown.addItem(ty) + self.sprite_type_dropdown.setEnabled(False) + self.sprite_type_layout.addWidget(self.sprite_type_dropdown) + self.layout().addLayout(self.sprite_type_layout) + + # Add a box to accept a sprite name + self.sprite_name_layout = QtWidgets.QHBoxLayout() + self.sprite_name_label = QtWidgets.QLabel("Sprite Name") + self.sprite_name_layout.addWidget(self.sprite_name_label) + self.sprite_name_input = QtWidgets.QLineEdit() + self.sprite_name_input.setText("unnamedSprite") + self.sprite_name_input.setEnabled(False) + self.sprite_name_layout.addWidget(self.sprite_name_input) + self.layout().addLayout(self.sprite_name_layout) + + # Add a selection option for the sprite optimization + self.optimization_layout = QtWidgets.QHBoxLayout() + self.optimization_label = QtWidgets.QLabel("Optimize For") + self.optimization_layout.addWidget(self.optimization_label) + self.optimization_dropdown = QtWidgets.QComboBox() + self.optimization_dropdown.addItem("Size") + self.optimization_dropdown.addItem("Quality") + self.optimization_dropdown.setEnabled(False) + self.optimization_layout.addWidget(self.optimization_dropdown) + self.layout().addLayout(self.optimization_layout) + + # Add a number input for the target FPS + self.fps_layout = QtWidgets.QHBoxLayout() + self.fps_label = QtWidgets.QLabel("Target FPS") + self.fps_layout.addWidget(self.fps_label) + self.fps_input = QtWidgets.QLineEdit() + self.fps_input.setText("24") + self.fps_input.setEnabled(False) + self.fps_layout.addWidget(self.fps_input) + self.layout().addLayout(self.fps_layout) + + # Add a checkbox to reverse the image order + self.reverse_layout = QtWidgets.QHBoxLayout() + self.reverse_label = QtWidgets.QLabel("Reverse Image Order") + self.reverse_layout.addWidget(self.reverse_label) + self.reverse_checkbox = QtWidgets.QCheckBox() + self.reverse_checkbox.setEnabled(False) + self.reverse_layout.addWidget(self.reverse_checkbox) + self.layout().addLayout(self.reverse_layout) + + # Add a seperator + self.layout().addWidget(qt_lines.QHLine()) + + # Add a button to start the stitching process + self.finishing_layout = QtWidgets.QHBoxLayout() + self.close_button = QtWidgets.QPushButton("Cancel") + self.close_button.clicked.connect(self.close) + self.finishing_layout.addWidget(self.close_button) + self.stitch_button = QtWidgets.QPushButton("Stitch") + self.stitch_button.clicked.connect(self.stitch_images) + self.stitch_button.setEnabled(False) + self.finishing_layout.addWidget(self.stitch_button) + self.layout().addLayout(self.finishing_layout) + + # Add space at the bottom in case window size is wrong + self.layout().addStretch() + + def load_png_dialog(self): + + # Open a file picker to search for the desired image + file_dialog = QtWidgets.QFileDialog() + file_dialog.setFileMode(QtWidgets.QFileDialog.ExistingFiles) + file_dialog.setNameFilter("Image Files (*.png *.jpg *.jpeg)") + file_dialog.setViewMode(QtWidgets.QFileDialog.Detail) + file_dialog.setLabelText(QtWidgets.QFileDialog.Accept, "Import") + file_dialog.setLabelText(QtWidgets.QFileDialog.Reject, "Cancel") + file_dialog.setWindowTitle("Import PNG Sequence") + file_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptOpen) + file_dialog.setDirectory(os.path.join(get_project_root(), "assets")) + + # If the user selected an image, import it + if file_dialog.exec_(): + # Enable all the disabled fields + self.sprite_type_dropdown.setEnabled(True) + self.sprite_name_input.setEnabled(True) + self.stitch_button.setEnabled(True) + self.optimization_dropdown.setEnabled(True) + self.fps_input.setEnabled(True) + self.reverse_checkbox.setEnabled(True) + + # Save the selected files + self.selected_files = file_dialog.selectedFiles() + + else: + logger.warning("No image selected") + return + + def stitch_images(self): + + # Check the naming convention + if not re.match(SPRITE_NAMING_VALIDATION, self.sprite_name_input.text()): + + # Pop up a warning + warning_dialog = QtWidgets.QMessageBox() + warning_dialog.setIcon(QtWidgets.QMessageBox.Warning) + warning_dialog.setText("Invalid Sprite Name") + warning_dialog.setInformativeText( + "The sprite name must be lower camel case\nExample: myShinySprite") + warning_dialog.setWindowTitle("Invalid Sprite Name") + warning_dialog.exec_() + + return + + # Check if we are about to overwrite an existing sprite + known_sprite_types = sprite_types.get_known_sprite_types() + ty_long_to_short_map = dict(map(reversed, known_sprite_types.items())) + sprite_type = ty_long_to_short_map[self.sprite_type_dropdown.currentText( + )] + sprite_name = self.sprite_name_input.text() + if stitcher.check_sprite_exists(sprite_type, sprite_name): + + # Pop up confirmation box + warning_dialog = QtWidgets.QMessageBox() + warning_dialog.setIcon(QtWidgets.QMessageBox.Warning) + warning_dialog.setText("Overwrite Sprite?") + warning_dialog.setInformativeText( + "A sprite with the name {}_{} already exists.\nDo you want to overwrite it?".format(sprite_type, sprite_name)) + warning_dialog.setWindowTitle("Overwrite Sprite?") + warning_dialog.setStandardButtons( + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + warning_dialog.setDefaultButton(QtWidgets.QMessageBox.No) + if warning_dialog.exec_() == QtWidgets.QMessageBox.No: + return + + + # Pop up an error if the inputted FPS is not a number + try: + fps = float(self.fps_input.text()) + except ValueError: + warning_dialog = QtWidgets.QMessageBox() + warning_dialog.setIcon(QtWidgets.QMessageBox.Warning) + warning_dialog.setText("Invalid FPS") + warning_dialog.setInformativeText( + "The FPS must be a number") + warning_dialog.setWindowTitle("Invalid FPS") + warning_dialog.exec_() + return + + # Perform the actual stitching action + stitcher.stitch_images_and_write_to_disk( + sprite_type, sprite_name, self.selected_files, self.optimization_dropdown.currentText() == "Size", float(self.fps_input.text()), self.reverse_checkbox.isChecked()) + + # Close the window + self.close() diff --git a/automation/project_root.py b/automation/project_root.py new file mode 100644 index 00000000..ee9cd8dd --- /dev/null +++ b/automation/project_root.py @@ -0,0 +1,11 @@ +"""Module to fetch the project root directory.""" + +import os + +def get_project_root() -> str: + """Gets the project root directory. + + Returns: + str: The project root directory. + """ + return os.environ.get("LD50_PROJECT_ROOT") \ No newline at end of file diff --git a/automation/qt_common/__init__.py b/automation/qt_common/__init__.py new file mode 100644 index 00000000..93145f6c --- /dev/null +++ b/automation/qt_common/__init__.py @@ -0,0 +1,2 @@ +"""This module is used to provide common functionality for Qt applications.""" + diff --git a/automation/qt_common/dialog.css b/automation/qt_common/dialog.css new file mode 100644 index 00000000..e7aa910e --- /dev/null +++ b/automation/qt_common/dialog.css @@ -0,0 +1,25 @@ +QWidget { + background-color: #444444; + color: white; +} + +QLabel[labelClass="label-title"] { + color: #58a5cc; + font-weight: bold; + font-size:25px; +} + +QComboBox:disabled { + background-color:#272727; + color: #444444; +} + +QCheckBox:disabled { + background-color:#272727; + color: #444444; +} + +QPushButton:disabled { + background-color:#272727; + color: #444444; +} \ No newline at end of file diff --git a/automation/qt_common/qt_app_wrapper.py b/automation/qt_common/qt_app_wrapper.py new file mode 100644 index 00000000..2939677c --- /dev/null +++ b/automation/qt_common/qt_app_wrapper.py @@ -0,0 +1,37 @@ +"""A wrapper that handles bootstrapping QT applications in environments that do not have QT support.""" + +# Load the logging system +import logging +logger = logging.getLogger("qt_common.utils") + +# We need to have a global to keep track of the QApplication +_qt_app = None + +class QtAppWrapper: + + def __init__(self, parent=None): + self.parent = parent + + def __enter__(self): + global _qt_app + + # If there is no parent, we must make a QApplication + if self.parent is None: + logger.info("No parent specified. Creating QApplication") + from PySide2 import QtWidgets + try: + if not _qt_app: + _qt_app = QtWidgets.QApplication([]) + except RuntimeError: + logger.error( + "Could not create QApplication. Is it already running?") + raise + + def __exit__(self, type, value, traceback): + global _qt_app + + # If there is no parent, we must run the QApplication ourselves + if self.parent is None: + logger.info("Running QApplication") + if _qt_app: + _qt_app.exec_() \ No newline at end of file diff --git a/automation/qt_common/qt_dialog_style.py b/automation/qt_common/qt_dialog_style.py new file mode 100644 index 00000000..e29bf2e2 --- /dev/null +++ b/automation/qt_common/qt_dialog_style.py @@ -0,0 +1,5 @@ +"""This module has an embedded CSS file defining the style of the dialogs.""" + +import pkgutil + +STYLESHEET = pkgutil.get_data(__name__, "./dialog.css").decode("utf-8") \ No newline at end of file diff --git a/automation/qt_common/qt_lines.py b/automation/qt_common/qt_lines.py new file mode 100644 index 00000000..e58ca606 --- /dev/null +++ b/automation/qt_common/qt_lines.py @@ -0,0 +1,17 @@ +"""Some utility classes for making lines in QT panels""" + +from PySide2 import QtWidgets + + +class QHLine(QtWidgets.QFrame): + def __init__(self): + super(QHLine, self).__init__() + self.setFrameShape(QtWidgets.QFrame.HLine) + self.setFrameShadow(QtWidgets.QFrame.Sunken) + + +class QVLine(QtWidgets.QFrame): + def __init__(self): + super(QVLine, self).__init__() + self.setFrameShape(QtWidgets.QFrame.VLine) + self.setFrameShadow(QtWidgets.QFrame.Sunken) \ No newline at end of file diff --git a/automation/qt_common/qt_window_center.py b/automation/qt_common/qt_window_center.py new file mode 100644 index 00000000..1659c594 --- /dev/null +++ b/automation/qt_common/qt_window_center.py @@ -0,0 +1,8 @@ +from PySide2 import QtWidgets + + +def center_window(w): + qr = w.frameGeometry() + cp = QtWidgets.QDesktopWidget().availableGeometry().center() + qr.moveCenter(cp) + w.move(qr.topLeft()) \ No newline at end of file diff --git a/automation/sprite_types.py b/automation/sprite_types.py new file mode 100644 index 00000000..6fbe21db --- /dev/null +++ b/automation/sprite_types.py @@ -0,0 +1,30 @@ +"""This file contains functions for reading known sprite types.""" + +from typing import Dict +import json +import os +from project_root import get_project_root + +import logging +logger = logging.getLogger(__name__) + +def get_known_sprite_types() -> Dict[str, str]: + """Gets a dictionary of known sprite types as a mapping from short name to friendly name + + Returns: + Dict[str, str]: Short name -> Friendly name + """ + + # Load our JSON file containing known sprite types + project_root = get_project_root() + logger.debug(f"Project root: {project_root}") + with open(os.path.join(project_root, "game", "dist", "known-sprite-types.json"), "r") as f: + known_sprite_types = json.load(f) + + # We need to re-shape the data + sprite_types = {} + for item in known_sprite_types: + sprite_types[item["short"]] = item["friendly"] + + + return sprite_types diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b11cbabd..91e925ec 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -3,7 +3,4 @@ 1. [Introduction](introduction.md) 2. [Getting Started](getting-started.md) 1. [Development Environment](development-environment.md) - 2. [Artist Information](artist-information.md) - 3. [Infrastructure Overview](infrastructure-overview.md) - 4. [Software Design](software-design.md) - 1. [Asset Manager](design-asset-manager.md) \ No newline at end of file + 3. [Using `anim_stitcher`](anim-stitcher.md) \ No newline at end of file diff --git a/docs/anim-stitcher.md b/docs/anim-stitcher.md new file mode 100644 index 00000000..0f6e2155 --- /dev/null +++ b/docs/anim-stitcher.md @@ -0,0 +1,39 @@ +# Using anim_stitcher + +`anim_stitcher` is a Python utility designed to allow artists to automatically convert their frames into sprite sheets with metadata. + +## Usage + +To launch `anim_stitcher`, you must first have all the Python dependencies installed. This means installing Python and pip, then running the following in the root of the project: + +```sh +python3 -m pip install -r requirements.txt +``` + +*For help with this, contact Evan.* + +### Launching the tool + +If you are on a Linux system, you can launch the tool with `./launch_anim_stitcher.sh`. + +Otherwise, open the project root folder, and run the `launch_anim_stitcher.bat` script. This will show a window similar to the following: + +![Anim Stitcher GUI](./anim_stitcher_gui.png) + +As you can see, I have already filled everything out for the `testFox` asset. + +**When selecting PNGs:** Make sure you shift-click to select multiple files. You don't want a spritesheet with only one frame in it. + +## Technical information + +`anim_stitcher` exports spritesheets to `game/dist/assets/anm/...`. Each spritesheet also has a metadata JSON file beside it. The filepaths are automatically chosen based on input in the GUI. + +An example output would be for an asset named `testFox` with the `Character` type. + +```text +... +game/dist/assets/anm/chr/chr_testFox: + - chr_testFox.png + - chr_testFox.anim_meta.json +... +``` diff --git a/docs/anim_stitcher_gui.png b/docs/anim_stitcher_gui.png new file mode 100644 index 00000000..476ec2d0 Binary files /dev/null and b/docs/anim_stitcher_gui.png differ diff --git a/docs/artist-information.md b/docs/artist-information.md deleted file mode 100644 index 0c302f08..00000000 --- a/docs/artist-information.md +++ /dev/null @@ -1 +0,0 @@ -# Artist Information diff --git a/docs/development-environment.md b/docs/development-environment.md index 144b42ba..b4f52585 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -2,7 +2,7 @@ ## Prerequisite Tooling -On all systems, you must have [Rust](https://www.rust-lang.org/tools/install), [git](https://git-scm.com/), and [cmake](https://cmake.org/download/) installed. +On all systems, you must have [Rust](https://www.rust-lang.org/tools/install), [git](https://git-scm.com/), [Python 3](https://www.python.org/) (with `pip`), and [cmake](https://cmake.org/download/) installed. ### Additional Dependencies for Linux @@ -30,6 +30,9 @@ If you are cloning via the CLI, you will need an additional step to ensure our f git clone https://github.com/Ewpratten/ludum-dare-50 cd ludum-dare-50 git submodule update --init --recursive + +# Optionally, pull in the dependencies for the artist tools +python3 -m pip install -r requirements.txt ``` ## First Build diff --git a/docs/infrastructure-overview.md b/docs/infrastructure-overview.md deleted file mode 100644 index 526e7b15..00000000 --- a/docs/infrastructure-overview.md +++ /dev/null @@ -1 +0,0 @@ -# Infrastructure Overview diff --git a/game/desktop_wrapper/src/main.rs b/game/desktop_wrapper/src/main.rs index 7120a73a..c57b96de 100644 --- a/game/desktop_wrapper/src/main.rs +++ b/game/desktop_wrapper/src/main.rs @@ -32,4 +32,7 @@ async fn main() { log::info!("Starting game"); game_logic::entrypoint(args.force_recreate_savefiles).await; log::info!("Goodbye!"); + + // Exit the program + std::process::exit(0); } diff --git a/game/dist/assets/.gitkeep b/game/dist/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/game/dist/assets/anm/chr/chr_testFox/chr_testFox.anim_meta.json b/game/dist/assets/anm/chr/chr_testFox/chr_testFox.anim_meta.json new file mode 100644 index 00000000..d0457605 --- /dev/null +++ b/game/dist/assets/anm/chr/chr_testFox/chr_testFox.anim_meta.json @@ -0,0 +1,105 @@ +{ + "sheet_height": 338, + "sheet_width": 9600, + "published_at": "2022-03-29 18:40:22", + "published_by": "ewpratten", + "fps": 24.0, + "frames": [ + { + "x": 0, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 600, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 1200, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 1800, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 2400, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 3000, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 3600, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 4200, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 4800, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 5400, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 6000, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 6600, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 7200, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 7800, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 8400, + "y": 0, + "width": 600, + "height": 338 + }, + { + "x": 9000, + "y": 0, + "width": 600, + "height": 338 + } + ] +} \ No newline at end of file diff --git a/game/dist/assets/anm/chr/chr_testFox/chr_testFox.png b/game/dist/assets/anm/chr/chr_testFox/chr_testFox.png new file mode 100644 index 00000000..5d82aca2 Binary files /dev/null and b/game/dist/assets/anm/chr/chr_testFox/chr_testFox.png differ diff --git a/game/dist/assets/anm/test/test_debugTexture/test_debugTexture.anim_meta.json b/game/dist/assets/anm/test/test_debugTexture/test_debugTexture.anim_meta.json new file mode 100644 index 00000000..7a85d82a --- /dev/null +++ b/game/dist/assets/anm/test/test_debugTexture/test_debugTexture.anim_meta.json @@ -0,0 +1,15 @@ +{ + "sheet_height": 64, + "sheet_width": 64, + "published_at": "2022-03-29 16:46:34", + "published_by": "ewpratten", + "fps": 24.0, + "frames": [ + { + "x": 0, + "y": 0, + "width": 64, + "height": 64 + } + ] +} \ No newline at end of file diff --git a/game/dist/assets/anm/test/test_debugTexture/test_debugTexture.png b/game/dist/assets/anm/test/test_debugTexture/test_debugTexture.png new file mode 100644 index 00000000..63706c1d Binary files /dev/null and b/game/dist/assets/anm/test/test_debugTexture/test_debugTexture.png differ diff --git a/game/dist/known-sprite-types.json b/game/dist/known-sprite-types.json new file mode 100644 index 00000000..ee6ceb87 --- /dev/null +++ b/game/dist/known-sprite-types.json @@ -0,0 +1,22 @@ +[ + { + "short": "chr", + "friendly": "Character" + }, + { + "short": "env", + "friendly": "Environment" + }, + { + "short": "prp", + "friendly": "Prop" + }, + { + "short": "cut", + "friendly": "Cutscene" + }, + { + "short": "test", + "friendly": "Test" + } +] \ No newline at end of file diff --git a/game/dist/project-constants.json b/game/dist/project-constants.json index 1ddcdee6..3064e0eb 100644 --- a/game/dist/project-constants.json +++ b/game/dist/project-constants.json @@ -4,6 +4,16 @@ 1080, 720 ], - "discord_app_id": 954413081918857276, - "target_fps": 60 + "target_fps": 60, + "discord": { + "app_id": 954413081918857276, + "artwork": { + "logo": "ld50-logo" + }, + "strings": { + "details.loading": "Watching the game load", + "details.sm_failure": "Game went FUBAR", + "details.main_menu": "In the main menu" + } + } } \ No newline at end of file diff --git a/game/dist/shaders/texture_render.fs b/game/dist/shaders/texture_render.fs new file mode 100644 index 00000000..f677e2d0 --- /dev/null +++ b/game/dist/shaders/texture_render.fs @@ -0,0 +1,36 @@ +#version 330 + +// TODO: for now this is just a scanline shader for testing + +// Input vertex attributes (from vertex shader) +in vec2 fragTexCoord; +in vec4 fragColor; + +// Input uniform values +uniform sampler2D texture0; +uniform vec4 colDiffuse; + +// Output fragment color +out vec4 finalColor; + +// NOTE: Add here your custom variables + +// NOTE: Render size values must be passed from code +const float renderWidth = 800; +const float renderHeight = 450; +float offset = 0.0; + +uniform float time; + +void main() +{ + float frequency = renderHeight/3.0; + // Scanlines method 2 + float globalPos = (fragTexCoord.y + offset) * frequency; + float wavePos = cos((fract(globalPos) - 0.5)*3.14); + + // Texel color fetching from texture sampler + vec4 texelColor = texture(texture0, fragTexCoord); + + finalColor = mix(vec4(0.0, 0.3, 0.0, 0.0), texelColor, wavePos); +} \ No newline at end of file diff --git a/game/game_logic/Cargo.toml b/game/game_logic/Cargo.toml index 491b31c5..be0d2754 100644 --- a/game/game_logic/Cargo.toml +++ b/game/game_logic/Cargo.toml @@ -6,17 +6,23 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -raylib = { version = "3.7", path = "../../third_party/raylib-rs/raylib" } +raylib = { version = "3.7", path = "../../third_party/raylib-rs/raylib", features = [ + "with_serde", + "nalgebra_interop" +] } sad_machine = { version = "1.0", path = "../../third_party/sm" } -tokio = { version = "1.17.0", featurs = ["fs"] } +tokio = { version = "1.17.0", features = ["fs", "sync"] } log = "0.4.14" profiling = "1.0.5" serde = { version = "1.0.136", features = ["derive"] } serde_json = "1.0.79" directories = "4.0.1" -chrono = { verison = "0.4.19", features = ["serde"] } +chrono = { version = "0.4.19", features = ["serde"] } discord-sdk = "0.3.0" rust-embed = { version = "6.2.0", features = ["compression"] } thiserror = "1.0.30" # nalgebra = { version = "0.30.1", features = ["serde"] } -approx = "0.5.1" \ No newline at end of file +approx = "0.5.1" +poll-promise = { version = "0.1.0", features = ["tokio"] } +tempfile = "3.3.0" +nalgebra = "0.30.1" \ No newline at end of file diff --git a/game/game_logic/src/asset_manager/datastore.rs b/game/game_logic/src/asset_manager/datastore.rs index 5d01de96..416e4b69 100644 --- a/game/game_logic/src/asset_manager/datastore.rs +++ b/game/game_logic/src/asset_manager/datastore.rs @@ -1,4 +1,9 @@ - +//! Access to the game's embedded files. +//! +//! ## Overview +//! +//! As described in the [`asset_manager`](../index.html) page, this file contains the actual access API for the game's embedded files. +//! To see how to use this, check out the [`rust-embed`](https://github.com/pyros2097/rust-embed) README. /// This structure is dynamically packed with the contents of `dist` at compile time /// /// This process allows us to only distribute a single binary, and have all the game assets stored in memory automatically. diff --git a/game/game_logic/src/asset_manager/json.rs b/game/game_logic/src/asset_manager/json.rs index e3a44fae..7d64932a 100644 --- a/game/game_logic/src/asset_manager/json.rs +++ b/game/game_logic/src/asset_manager/json.rs @@ -1,3 +1,5 @@ +//! Utilities for loading JSON from the embedded filesystem. + use serde::de::DeserializeOwned; use super::datastore::InternalData; @@ -15,6 +17,13 @@ pub enum InternalJsonLoadError { } /// Load an embedded JSON file +/// +/// **This is a blocking function call** +/// +/// ## Errors +/// +/// This may raise an error either because the requested asset was not found, or because the JSON data could not be deserialized. +/// See [`InternalJsonLoadError`](enum.InternalJsonLoadError.html) for more information. pub fn load_json_structure<'a, T: DeserializeOwned>( dist_path: &str, ) -> Result { diff --git a/game/game_logic/src/asset_manager/mod.rs b/game/game_logic/src/asset_manager/mod.rs index fd2daa73..2b5a023f 100644 --- a/game/game_logic/src/asset_manager/mod.rs +++ b/game/game_logic/src/asset_manager/mod.rs @@ -1,2 +1,27 @@ -pub mod datastore; -pub mod json; \ No newline at end of file +//! Embedded asset management. +//! +//! ## Overview +//! +//! `asset_manager` is one of the most important modules in this project. It handles loading and packaging of in-game resources. +//! Generally in a game, you might distribute an executable along with a zip of everything needed to run, +//! but we have had some issues with this before on systems with restrictive file permissions (cough OSX). +//! +//! To make the game distribution process easier, we embed our resources directly into the executable's data section using +//! [`rust-embed`](https://github.com/pyros2097/rust-embed). This means we only have to distribute one single file to players. +//! +//! ## Debug vs. Release mode +//! +//! When the game is built in debug mode (with `cargo build`), the resources are *not* packaged into the game. +//! Instead, they are read from disk, allowing us to modify them while the game is running, and speeding up the compile times. +//! +//! When the game is built in release mode (with `cargo build --release`), the resources are packaged into the game as described above. +//! This means the game will load faster, but also use more RAM. + +mod datastore; +pub use datastore::InternalData; +mod json; +pub use json::{InternalJsonLoadError, load_json_structure}; +mod sprite_types; +pub use sprite_types::{KnownSpriteType, load_known_sprite_types}; +mod texture; +pub use texture::{load_texture_from_internal_data, ResourceLoadError}; \ No newline at end of file diff --git a/game/game_logic/src/asset_manager/sprite_types.rs b/game/game_logic/src/asset_manager/sprite_types.rs new file mode 100644 index 00000000..b1ac063f --- /dev/null +++ b/game/game_logic/src/asset_manager/sprite_types.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; + +use super::InternalData; + +/// The structure backing the `dist/known-sprite-types.json` file +#[derive(Debug, Clone, Deserialize)] +pub struct KnownSpriteType { + /// Sprite short name (used in filenames) + #[serde(rename = "short")] + pub short_name: String, + /// Sprite long name + #[serde(rename = "friendly")] + pub friendly_name: String, +} + +/// Loads a list of all known sprite types from the definitions file +pub fn load_known_sprite_types() -> Result, serde_json::Error> { + // Load the json file from the embedded data as a string + let data = InternalData::get("known-sprite-types.json").unwrap().data; + + // Deserialize the json string into a rust structure + let json_structure: Vec = serde_json::from_slice(&data)?; + Ok(json_structure) +} diff --git a/game/game_logic/src/asset_manager/texture.rs b/game/game_logic/src/asset_manager/texture.rs new file mode 100644 index 00000000..36a819d8 --- /dev/null +++ b/game/game_logic/src/asset_manager/texture.rs @@ -0,0 +1,64 @@ +//! Code for loading textures from RAM to VRAM +//! +//! Largely coppied from last year: https://github.com/Ewpratten/ludum-dare-49/blob/master/game/src/utilities/datastore.rs + +use std::path::Path; + +use raylib::{texture::Texture2D, RaylibHandle, RaylibThread}; +use tempfile::tempdir; + +use crate::asset_manager::InternalData; + +#[derive(Debug, thiserror::Error)] +pub enum ResourceLoadError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Could not load embedded asset: {0}")] + AssetNotFound(String), + #[error("Generic error: {0}")] + Generic(String), +} + +/// Loads an embedded texture into VRAM. +/// +/// # Technical Info +/// In this application, we are using `rust_embed` to embed static assets directly inside the executable. +/// This has the limitation of none of the assets being "real files", which causes an issue with Raylib. +/// Raylib requires a "real file" in order to load data into VRAM (without digging into `unsafe` dark magic). +/// The solution is to temporarily write the assets to disk, and then load them from disk. +/// We must also preserve the file extension, so the Raylib file loader can parse them correctly. +pub fn load_texture_from_internal_data( + raylib_handle: &mut RaylibHandle, + thread: &RaylibThread, + path: &str, +) -> Result { + // Create a temp file path to work with + let temp_dir = tempdir()?; + debug!( + "Created temporary directory for passing embedded data to Raylib: {}", + temp_dir.path().display() + ); + let tmp_path = temp_dir.path().join(Path::new(path).file_name().unwrap()); + + // Unpack the raw image data to a real file on the local filesystem so raylib will read it correctly + std::fs::write( + &tmp_path, + &InternalData::get(path) + .ok_or(ResourceLoadError::AssetNotFound(path.to_string()))? + .data, + )?; + + // Call through via FFI to re-load the file + let texture = raylib_handle + .load_texture(thread, tmp_path.to_str().unwrap()) + .map_err(ResourceLoadError::Generic)?; + + // Close the file + debug!( + "Dropping temporary directory: {}", + temp_dir.path().display() + ); + temp_dir.close()?; + + Ok(texture) +} diff --git a/game/game_logic/src/discord/ipc.rs b/game/game_logic/src/discord/ipc.rs new file mode 100644 index 00000000..4402c8a6 --- /dev/null +++ b/game/game_logic/src/discord/ipc.rs @@ -0,0 +1,80 @@ +//! Discord Rich Presence utilities + +use discord_sdk::{ + activity::{Activity, ActivityBuilder}, + user::User, + wheel::Wheel, + Discord, DiscordApp, Subscriptions, +}; +use tokio::time::error::Elapsed; + +#[derive(Debug, thiserror::Error)] +pub enum DiscordError { + #[error(transparent)] + Sdk(#[from] discord_sdk::Error), + #[error(transparent)] + AwaitConnection(#[from] tokio::sync::watch::error::RecvError), + #[error("Could not connect")] + Connection, + #[error(transparent)] + ConnectionTimeout(#[from] Elapsed), +} + +/// The client wrapper for Discord RPC +pub struct DiscordRpcClient { + pub discord: Discord, + pub user: User, + pub wheel: Wheel, +} + +impl DiscordRpcClient { + /// Creates a new `DiscordRpcClient` + pub async fn new(app_id: i64, subscriptions: Subscriptions) -> Result { + // Create a new wheel + let (wheel, handler) = Wheel::new(Box::new(|err| { + error!("Encountered an error: {}", err); + })); + let mut user = wheel.user(); + + // Create the client + let discord = Discord::new( + DiscordApp::PlainId(app_id), + subscriptions, + Box::new(handler), + )?; + + // Wait for the discord handshake + info!("Waiting for Discord client handshake"); + user.0.changed().await?; + info!("Discord handshake success"); + + // Fetch the final user object + let user = match &*user.0.borrow() { + discord_sdk::wheel::UserState::Connected(u) => Ok(u.clone()), + discord_sdk::wheel::UserState::Disconnected(_) => Err(DiscordError::Connection), + }?; + + Ok(Self { + discord, + user, + wheel, + }) + } + + /// Clears the user rich presence + #[profiling::function] + #[allow(dead_code)] + pub async fn clear_rich_presence(&self) -> Result, discord_sdk::Error> { + self.discord + .update_activity(ActivityBuilder::default()) + .await + } + + /// Sets the user rich presence + pub async fn set_rich_presence( + &self, + activity: ActivityBuilder, + ) -> Result, discord_sdk::Error> { + self.discord.update_activity(activity).await + } +} diff --git a/game/game_logic/src/discord/mod.rs b/game/game_logic/src/discord/mod.rs index 74413437..894b71d2 100644 --- a/game/game_logic/src/discord/mod.rs +++ b/game/game_logic/src/discord/mod.rs @@ -1,4 +1,120 @@ -//! This module contains code needed for interacting with a local Discord instance. +//! Interfacing with Discord +//! +//! ## Overview +//! +//! When the game is run at the same time as a Discord client on a computer, it will attach to the user's +//! account and display [Rich Presence](https://discord.com/rich-presence) information. +//! +//! This is handled through the [`discord-sdk`](https://github.com/EmbarkStudios/discord-sdk) crate, +//! but still requires some additional code to get everything set up. +//! +//! Our main focuses in this module are: +//! +//! - Ensuring that the game does not crash when Discord is not running +//! - Ensuring that Discord can not pause the game by taking too long to respond to an update +//! +//! To solve these, we run this task in its own thread, and talk to it through +//! Tokio's [`mpsc`](https://docs.rs/tokio/latest/tokio/sync/mpsc/fn.channel.html) +//! implementation (as we are already working in an async context). +//! +//! ## Usage +//! +//! ```rust,no_run +//! let app_id = 123456789; +//! +//! // Connect to discord +//! let discord = DiscordRpcThreadHandle::new(app_id).await.unwrap(); +//! let event_loop_discord_tx = discord.get_channel(); +//! +//! // When this variable is dropped, the connection is closed, so keep this around +//! let discord_task_handle = discord.begin_thread_non_blocking(); +//! +//! // We can then send signals any time we want +//! event_loop_discord_tx.send(DiscordRpcSignal::BeginGameTimer).await.unwrap(); +//! ``` mod signal; +use std::time::Duration; + pub use signal::DiscordRpcSignal; +use tokio::{ + sync::{mpsc::Receiver, mpsc::Sender}, + task::JoinHandle, +}; + +use self::{ipc::DiscordRpcClient, signal::StatefulDiscordRpcSignalHandler}; +mod ipc; +pub use ipc::DiscordError; + +/// How long to wait before we give up on connecting to Discord. +const DISCORD_CONNECT_TIMEOUT_SECONDS: u64 = 5; + +/// A cross-thread communication channel for sending Discord RPC events. +pub type DiscordChannel = Sender; + +pub struct DiscordRpcThreadHandle { + tx_chan: DiscordChannel, + rx_chan: Receiver, + internal_client: Option, + state: StatefulDiscordRpcSignalHandler, +} + +impl DiscordRpcThreadHandle { + /// Construct a new `DiscordRpcThreadHandle` + pub async fn new(app_id: i64) -> Result { + // Create the Discord client + info!("Trying to locate and connect to a local Discord process for RPC. Will wait up to {} seconds before timing out", DISCORD_CONNECT_TIMEOUT_SECONDS); + let rpc_client = match tokio::time::timeout( + Duration::from_secs(DISCORD_CONNECT_TIMEOUT_SECONDS), + DiscordRpcClient::new(app_id, discord_sdk::Subscriptions::ACTIVITY), + ) + .await + { + Ok(client) => Some(client?), + Err(t) => { + error!( + "Timed out trying to connect to Discord RPC. Duration: {}", + t + ); + None + } + }; + info!("Successfully connected to Discord"); + + // Set up channels + let (tx, rx) = tokio::sync::mpsc::channel(5); + + Ok(Self { + tx_chan: tx, + rx_chan: rx, + internal_client: rpc_client, + state: StatefulDiscordRpcSignalHandler::default(), + }) + } + + /// Get access to the inter-thread channel for communicating to discord + pub fn get_channel(&self) -> DiscordChannel { + self.tx_chan.clone() + } + + /// Run the inner communication task in an async context + pub fn begin_thread_non_blocking(mut self) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + // Handle any possible incoming events + match self.rx_chan.try_recv() { + Ok(signal) => match self.internal_client { + Some(ref client) => { + client + .set_rich_presence(self.state.apply(signal)) + .await + .unwrap(); + } + None => { /* The client could not connect */ } + }, + Err(_) => { /* Do Nothing */ } + } + } + }) + } +} diff --git a/game/game_logic/src/discord/signal.rs b/game/game_logic/src/discord/signal.rs index 2bb9004c..d3d71c68 100644 --- a/game/game_logic/src/discord/signal.rs +++ b/game/game_logic/src/discord/signal.rs @@ -6,9 +6,10 @@ //! The game thread can then send `DiscordRpcSignal` values through an `mpsc` sender, which will be received by the Discord RPC client thread. use chrono::Utc; -use discord_sdk::activity::{ActivityBuilder, Assets, IntoTimestamp}; +use discord_sdk::activity::{ActivityBuilder, Assets}; /// Definitions of signals that can be sent to the Discord RPC thread to control how discord displays game status. +#[derive(Debug, Clone)] pub enum DiscordRpcSignal { /// Signal to begin a game timer (Discord will display `XX:XX elapsed`) BeginGameTimer, @@ -50,7 +51,6 @@ pub struct StatefulDiscordRpcSignalHandler { } impl StatefulDiscordRpcSignalHandler { - /// Apply a signal to generate a new activity pub fn apply(&mut self, signal: DiscordRpcSignal) -> ActivityBuilder { // Fill in the data based on the contents of the signal diff --git a/game/game_logic/src/global_resource_package.rs b/game/game_logic/src/global_resource_package.rs new file mode 100644 index 00000000..4a9c6ed8 --- /dev/null +++ b/game/game_logic/src/global_resource_package.rs @@ -0,0 +1,29 @@ +//! Global resources +//! +//! ## Overview +//! +//! This module contains a structure for all resources that are needed through the whole game (sounds, fonts, etc.). +//! These are automatically loaded during the first loading screen, and are then passed around the game as needed. +//! +//! ## How this is loaded +//! +//! The resources are loaded via [`asset_manager`](./asset_manager/index.html) in their own thread so we do not block the renderer. + +use poll_promise::Promise; +use raylib::{RaylibHandle, RaylibThread}; + +/// Global resource package +#[derive(Debug)] +pub struct GlobalResources {} + +impl GlobalResources { + /// Load the resources (**blocking**) + /// + /// This should not be called more than once. + pub async fn load( + raylib: &mut RaylibHandle, + rl_thread: &RaylibThread, + ) -> Self { + Self {} + } +} diff --git a/game/game_logic/src/lib.rs b/game/game_logic/src/lib.rs index dcbff8a8..02137318 100644 --- a/game/game_logic/src/lib.rs +++ b/game/game_logic/src/lib.rs @@ -1,15 +1,44 @@ //! This file is the main entry point for the game logic. +//! +//! ## Overview +//! +//! The main function in this module is `entrypoint()`. This is called from `desktop_wrapper` to start the game. +//! +//! This module also includes all the other sub-modules of the game. If you are viewing this document from the web, click on the modules below to see more info. +//! +//! ## Programming Guide +//! +//! The game code is split into two parts: the core code, and the actual game logic. +//! +//! [@ewpratten](https://github.com/ewpratten) has written most of the core code to bootstrap the game, and provide convenience functions. +//! This stuff probably won't need to be touched. +//! Most of the game logic is expected to live in `src/scenes` and `src/model` (rendering and data). +//! +//! ## Important Functions and Files +//! +//! - If you are wanting to write rendering code, check out [`process_ingame_frame`](scenes/fn.process_ingame_frame.html). +//! - If you want to have something load at the start of the game and stay in memory, check out [`GlobalResources`](global_resource_package/struct.GlobalResources.html). +//! - If you want to add data to the save state file or settings file, check out the [`persistent`](persistent/index.html) module. +#![doc(issue_tracker_base_url = "https://github.com/Ewpratten/ludum-dare-50/issues/")] -use crate::{asset_manager::json::load_json_structure, project_constants::ProjectConstants}; +use crate::{ + asset_manager::load_json_structure, + discord::{DiscordRpcSignal, DiscordRpcThreadHandle}, + project_constants::ProjectConstants, +}; #[macro_use] extern crate approx; // For the macro `relative_eq!` +#[macro_use] +extern crate log; // For the `info!`, `warn!`, etc. macros -pub mod asset_manager; -pub mod discord; -pub mod persistent; -pub mod project_constants; -pub mod rendering; +pub(crate) mod asset_manager; +pub(crate) mod discord; +pub(crate) mod global_resource_package; +pub(crate) mod persistent; +pub(crate) mod project_constants; +pub(crate) mod rendering; +pub(crate) mod scenes; /// This is the game logic entrypoint. Despite being async, /// this is expected to block the main thread for rendering and stuff. @@ -32,18 +61,28 @@ pub async fn entrypoint(force_recreate_savefiles: bool) { persistent::save_state::GameSaveState::load_or_create(force_recreate_savefiles) .expect("Failed to parse game save state from disk. Possibly corrupt file?"); + // Connect to Discord + let discord = DiscordRpcThreadHandle::new(project_constants.discord.app_id) + .await + .expect("Failed to connect to Discord RPC"); + let event_loop_discord_tx = discord.get_channel(); + let discord_task_handle = discord.begin_thread_non_blocking(); + // Blocking call to the graphics rendering loop. rendering::event_loop::handle_graphics_blocking( |builder| { builder .msaa_4x() - .vsync() + // .vsync() .title(project_constants.game_name.as_str()) .height(project_constants.base_window_size.1 as i32) .width(project_constants.base_window_size.0 as i32); }, - settings.target_fps, - ); + project_constants.target_fps, + &project_constants, + event_loop_discord_tx, + ) + .await; // Clean up any resources settings @@ -52,4 +91,5 @@ pub async fn entrypoint(force_recreate_savefiles: bool) { save_state .save() .expect("Could not save game save state to disk."); + discord_task_handle.abort(); } diff --git a/game/game_logic/src/persistent/settings.rs b/game/game_logic/src/persistent/settings.rs index 4b588d20..252bdec7 100644 --- a/game/game_logic/src/persistent/settings.rs +++ b/game/game_logic/src/persistent/settings.rs @@ -9,14 +9,13 @@ use serde::{Deserialize, Serialize}; /// Please don't add anything relating to gameplay though (no coins, health, etc.). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersistentGameSettings { - /// The target framerate for the game - pub target_fps: u32, + // TODO: Add data here. } // Add any default values here. impl Default for PersistentGameSettings { fn default() -> Self { - Self { target_fps: 60 } + Self {} } } diff --git a/game/game_logic/src/project_constants.rs b/game/game_logic/src/project_constants.rs index 8c7a15c2..4350a297 100644 --- a/game/game_logic/src/project_constants.rs +++ b/game/game_logic/src/project_constants.rs @@ -1,5 +1,33 @@ +//! The rust side of the `dist/project-constants.json` file +//! +//! ## Overview +//! +//! This file contains a structure defining all data we want to load from the project constants file. +//! Feel free to add anything you'd like here, just make sure the relavant data is also written in the JSON file so the game doesn't crash. +//! You can treat these as constants. I just prefer storing this kind of data in JSON rather than hard-coding it in the program. +//! +//! ## How this is loaded +//! +//! Somewhere in `lib.rs`, a call is made to load this through the `asset_manager`. +//! Its all already set up, so you shouldn't have to worry about the logistics. + +use std::collections::HashMap; + use serde::Deserialize; +/// Constants relating to Discord +#[derive(Debug, Deserialize)] +pub struct DiscordConstants { + /// The Discord application ID + pub app_id: i64, + + /// Artwork name mapping + pub artwork: HashMap, + + /// Strings + pub strings: HashMap, +} + /// This structure is filled with the contents of `dist/project-constants.json` at runtime #[derive(Debug, Deserialize)] pub struct ProjectConstants { @@ -9,8 +37,8 @@ pub struct ProjectConstants { /// The window size to use on launch pub base_window_size: (u32, u32), - /// The Discord application ID - pub discord_app_id: u64, + /// The Discord constants + pub discord: DiscordConstants, /// The target framerate of the game pub target_fps: u32, diff --git a/game/game_logic/src/rendering/core_renderer_sm.rs b/game/game_logic/src/rendering/core_renderer_sm.rs new file mode 100644 index 00000000..9758f8f4 --- /dev/null +++ b/game/game_logic/src/rendering/core_renderer_sm.rs @@ -0,0 +1,20 @@ +//! This module contains state machine definitions for the backend rendering system. + +sad_machine::state_machine! { + RenderBackendStates { + InitialStates { + Preload, SmFailed + } + FinishPreload { + Preload => Loading + } + FinishLoading { + Loading => RenderGame + } + ForceSmFailure { + Preload => SmFailed, + Loading => SmFailed, + RenderGame => SmFailed + } + } +} diff --git a/game/game_logic/src/rendering/event_loop.rs b/game/game_logic/src/rendering/event_loop.rs index d0dc3279..54aa6d00 100644 --- a/game/game_logic/src/rendering/event_loop.rs +++ b/game/game_logic/src/rendering/event_loop.rs @@ -1,10 +1,35 @@ +//! The Event Loop module +//! +//! ## Overview +//! +//! This is the code that handles beginning each frame and ending it. Do not try to add your own game logic in here. +//! The event loop function has its own statemachine (`core_renderer_sm.rs`) that handles the current action. +//! +//! You can think of this as a bit of bootstrap code for the game. All that happens directly here is rendering of the loading screen and a bit of error handling. + +use std::cell::RefCell; + +use crate::discord::DiscordChannel; +use crate::project_constants::ProjectConstants; +use crate::rendering::core_renderer_sm::{PreloadState, RenderBackendStates}; +use crate::rendering::screens::sm_failure_screen; +use crate::scenes::SceneRenderDelegate; use raylib::RaylibBuilder; +use raylib::consts::KeyboardKey; +use raylib::prelude::RaylibDraw; /// Will begin rendering graphics. Returns when the window closes -pub fn handle_graphics_blocking(config: ConfigBuilder, target_frames_per_second: u32) -where +pub async fn handle_graphics_blocking( + config: ConfigBuilder, + target_frames_per_second: u32, + constants: &ProjectConstants, + discord_signaling: DiscordChannel, +) where ConfigBuilder: FnOnce(&mut RaylibBuilder), { + // Set up the backend rendering state machine + let mut backend_sm = RenderBackendStates::preload(); + // Let the caller configure Raylib's internal window stuff let (mut raylib_handle, raylib_thread) = { log::trace!("Configuring Raylib"); @@ -17,9 +42,85 @@ where raylib_handle.set_exit_key(None); raylib_handle.set_target_fps(target_frames_per_second); - // Run the event loop + // Set up the internal screens + let mut loading_screen = crate::rendering::screens::loading_screen::LoadingScreen::new(); + let mut sm_failure_screen = sm_failure_screen::SmFailureScreen::new(); + + // Set up the main render delegate + let mut render_delegate = + SceneRenderDelegate::on_game_start(&mut raylib_handle, &raylib_thread); + + // Handle loading the resources and rendering the loading screen log::trace!("Running event loop"); while !raylib_handle.window_should_close() { + // Handle state machine updates + match backend_sm { + RenderBackendStates::Preload(m @ PreloadState::FromInit) => { + backend_sm = m.finish_preload(); + } + RenderBackendStates::Loading(ref m) => { + if loading_screen + .render( + &mut raylib_handle, + &raylib_thread, + &discord_signaling, + &constants, + ) + .await + { + backend_sm = m.finish_loading(); + } + } + _ => break, + }; + + // Tell the profiler that we ended the frame + profiling::finish_frame!(); + } + log::info!("Finished loading game"); + + // Get access to the global resources + let global_resources = loading_screen + .resources + .expect("Failed to get global resources"); + + // Tracker for if we are showing the FPS counter + let mut show_fps_counter = false; + + // Run the event loop + while !raylib_handle.window_should_close() { + // Handle state machine updates + match backend_sm { + RenderBackendStates::SmFailed(ref m) => { + sm_failure_screen + .render( + &mut raylib_handle, + &raylib_thread, + &discord_signaling, + &constants, + ) + .await; + } + RenderBackendStates::RenderGame(ref m) => { + render_delegate.process_ingame_frame( + &mut raylib_handle, + &raylib_thread, + &discord_signaling, + &global_resources, + ); + } + _ => backend_sm = RenderBackendStates::sm_failed(), + }; + + // Check for F3 being pressed + if raylib_handle.is_key_pressed(KeyboardKey::KEY_F3) { + show_fps_counter = !show_fps_counter; + } + + // Show the FPS counter + if show_fps_counter { + raylib_handle.begin_drawing(&raylib_thread).draw_fps(10, 10); + } // Tell the profiler that we ended the frame profiling::finish_frame!(); diff --git a/game/game_logic/src/rendering/mod.rs b/game/game_logic/src/rendering/mod.rs index 84cb5be0..3d37aae0 100644 --- a/game/game_logic/src/rendering/mod.rs +++ b/game/game_logic/src/rendering/mod.rs @@ -1,3 +1,6 @@ //! This module contains lower level rendering logic. -pub mod event_loop; \ No newline at end of file +pub mod event_loop; +pub mod utilities; +pub mod screens; +mod core_renderer_sm; \ No newline at end of file diff --git a/game/game_logic/src/rendering/screens/loading_screen.rs b/game/game_logic/src/rendering/screens/loading_screen.rs new file mode 100644 index 00000000..ae1939ad --- /dev/null +++ b/game/game_logic/src/rendering/screens/loading_screen.rs @@ -0,0 +1,72 @@ +//! Handles loading the global resources and playing an intro animation +//! +//! ## Overview +//! +//! This module contains `LoadingScreen` which will perform multi-threaded resource loading while rendering a loading animation. +//! +//! ## Whats happening +//! +//! - Discord RPC is set +//! - Resources are loaded +//! - Animation is rendered + +use poll_promise::Promise; +use raylib::prelude::*; + +use crate::{ + discord::{DiscordChannel, DiscordRpcSignal}, + global_resource_package::GlobalResources, + project_constants::ProjectConstants, +}; + +pub struct LoadingScreen { + pub resources: Option, + has_updated_discord_status: bool, +} + +impl LoadingScreen { + /// Construct a new `LoadingScreen` + pub fn new() -> Self { + Self { + resources: None, + has_updated_discord_status: false, + } + } + + pub async fn render( + &mut self, + raylib: &mut RaylibHandle, + rl_thread: &RaylibThread, + discord: &DiscordChannel, + constants: &ProjectConstants, + ) -> bool { + // Handle updating the Discord status + if !self.has_updated_discord_status { + discord + .send(DiscordRpcSignal::ChangeDetails { + details: constants + .discord + .strings + .get("details.loading") + .unwrap() + .to_owned(), + party_status: None, + }) + .await + .unwrap(); + self.has_updated_discord_status = true; + } + + // Begin loading resources if we haven't already + if let None = self.resources { + self.resources = Some(GlobalResources::load(raylib, rl_thread).await); + } + + // Draw some graphics + let mut d = raylib.begin_drawing(&rl_thread); + d.clear_background(raylib::color::Color::BLACK); + + + true + } +} diff --git a/game/game_logic/src/rendering/screens/mod.rs b/game/game_logic/src/rendering/screens/mod.rs new file mode 100644 index 00000000..28f4670c --- /dev/null +++ b/game/game_logic/src/rendering/screens/mod.rs @@ -0,0 +1,2 @@ +pub mod loading_screen; +pub mod sm_failure_screen; \ No newline at end of file diff --git a/game/game_logic/src/rendering/screens/sm_failure_screen.rs b/game/game_logic/src/rendering/screens/sm_failure_screen.rs new file mode 100644 index 00000000..8611eafa --- /dev/null +++ b/game/game_logic/src/rendering/screens/sm_failure_screen.rs @@ -0,0 +1,55 @@ +use raylib::prelude::*; + +use crate::{discord::{DiscordChannel, DiscordRpcSignal}, project_constants::ProjectConstants}; + +#[derive(Debug)] +pub struct SmFailureScreen { + has_updated_discord_status: bool, +} + +impl SmFailureScreen { + /// Construct a new `SmFailureScreen` + pub fn new() -> Self { + Self { + has_updated_discord_status: false, + } + } + + pub async fn render( + &mut self, + raylib: &mut RaylibHandle, + rl_thread: &RaylibThread, + discord: &DiscordChannel, + constants: &ProjectConstants, + ) -> bool { + // Handle updating the Discord status + if !self.has_updated_discord_status { + discord + .send(DiscordRpcSignal::ChangeDetails { + details: constants + .discord + .strings + .get("details.sm_failure") + .unwrap() + .to_owned(), + party_status: None, + }) + .await + .unwrap(); + self.has_updated_discord_status = true; + } + + // Render the error message + let mut d = raylib.begin_drawing(&rl_thread); + d.clear_background(raylib::color::Color::RED); + d.draw_text( + "Backend Rendering Broke.\nYou should not be seeing this!", + 10, + 10, + 40, + raylib::color::Color::WHITE, + ); + + false + } +} diff --git a/game/game_logic/src/rendering/utilities/anim_texture.rs b/game/game_logic/src/rendering/utilities/anim_texture.rs new file mode 100644 index 00000000..bbe593de --- /dev/null +++ b/game/game_logic/src/rendering/utilities/anim_texture.rs @@ -0,0 +1,219 @@ +//! This module handles the code for rendering framerate-locked animations from textures + +use chrono::{DateTime, Utc}; +use nalgebra::Vector2; +use raylib::{ + color::Color, + math::Rectangle, + prelude::{RaylibDraw, RaylibDrawHandle}, + texture::Texture2D, + RaylibHandle, RaylibThread, +}; +use serde::Deserialize; + +use crate::asset_manager::{ + load_json_structure, load_known_sprite_types, load_texture_from_internal_data, + InternalJsonLoadError, +}; + +/// Possible errors to be thrown during the animation texture loading process +#[derive(Debug, thiserror::Error)] +pub enum AnimatedTextureLoadError { + #[error(transparent)] + MetadataLoadError(#[from] InternalJsonLoadError), + #[error(transparent)] + KnownSpriteTypesLoadError(#[from] serde_json::Error), + #[error("Invalid Sprite Type: {0}")] + InvalidSpriteType(String), + #[error(transparent)] + TextureLoadError(#[from] crate::asset_manager::ResourceLoadError), +} + +/// Definition for the structure describing a frame's size and position in a texture +#[derive(Debug, Clone, Deserialize)] +struct FrameTextureDescriptor { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Into for FrameTextureDescriptor { + fn into(self) -> Rectangle { + Rectangle::new(self.x, self.y, self.width, self.height) + } +} + +/// Definition for the metadata structure attached to each spritesheet +#[derive(Debug, Clone, Deserialize)] +struct AnimatedTextureMetadata { + pub sheet_height: u64, + pub sheet_width: u64, + pub fps: f32, + pub frames: Vec, +} + +#[derive(Debug)] +pub struct AnimatedTexture { + /// The whole internal spritesheet + texture: Texture2D, + /// The metadata describing the spritesheet + texture_metadata: AnimatedTextureMetadata, + /// a list of source rects to reduce memory allocation needs during render time + texture_source_rects: Vec, + /// The animation start timestamp + start_time: Option>, +} + +impl AnimatedTexture { + /// Construct a new `AnimatedTexture` + /// + /// This will load all resources from RAM or disk. May take a while. + #[profiling::function] + pub fn new( + raylib_handle: &mut RaylibHandle, + thread: &RaylibThread, + sprite_type: &str, + sprite_name: &str, + ) -> Result { + // Try to convert the sprite type string to a real type + let known_sprite_types = load_known_sprite_types()?; + let sprite_type_obj = known_sprite_types.iter().find(|known_sprite_type| { + known_sprite_type.short_name == sprite_type + || known_sprite_type.friendly_name == sprite_type + }); + if let None = sprite_type_obj { + error!("Invalid sprite type supplied: {}", sprite_type); + return Err(AnimatedTextureLoadError::InvalidSpriteType( + sprite_type.to_string(), + )); + } + let sprite_type_obj = sprite_type_obj.unwrap(); + + // Now, we can construct the paths to the texture and metadata + let parent_dir_path = format!( + "assets/anm/{}/{}_{}", + sprite_type_obj.short_name, sprite_type_obj.short_name, sprite_name + ); + let metadata_file_path = format!( + "{}/{}_{}.anim_meta.json", + parent_dir_path, sprite_type_obj.short_name, sprite_name + ); + let texture_file_path = format!( + "{}/{}_{}.png", + parent_dir_path, sprite_type_obj.short_name, sprite_name + ); + + // Attempt to load the metadata + let texture_metadata: AnimatedTextureMetadata = load_json_structure(&metadata_file_path)?; + let source_rects = texture_metadata + .frames + .iter() + .map(|frame_descriptor| frame_descriptor.clone().into()) + .collect(); + + // Attempt to load the texture itself + let texture = load_texture_from_internal_data(raylib_handle, thread, &texture_file_path)?; + + Ok(Self { + texture, + texture_metadata, + texture_source_rects: source_rects, + start_time: None, + }) + } + + /// Render a single frame to the screen + #[profiling::function] + pub fn render_frame_by_index( + &self, + draw_handle: &mut RaylibDrawHandle, + index: usize, + position: Vector2, + percent_scale: Option>, + origin: Option>, + rotation: Option, + tint: Option, + ) { + // Get the frame-specific metadata + let metadata = &self.texture_metadata.frames[index]; + + // Build a source rectangle + let source = self.texture_source_rects[index]; + + // Build a destination rectangle + let scaler = percent_scale.unwrap_or(Vector2::new(1.0, 1.0)); + let destination = Rectangle::new( + position.x, + position.y, + metadata.width * scaler.x, + metadata.height * scaler.y, + ); + let origin: raylib::core::math::Vector2 = + origin.unwrap_or_else(|| Vector2::::zeros()).into(); + // debug!("{:?} -> {:?}", source, destination); + + // Render the frame + draw_handle.draw_texture_pro( + &self.texture, + source, + destination, + origin, + rotation.unwrap_or(0.0), + tint.unwrap_or(Color::WHITE), + ); + } + + /// Clear the internal tracker for when the animation started + /// + /// This will bring the animation back to frame 1. Useful for non-looping rendering + pub fn reset_animation(&mut self) { + self.start_time = None; + } + + /// Get the current frame index + pub fn get_current_frame_index(&self) -> Option { + self.start_time.map(|start_time| { + let elapsed_time_ms = Utc::now() + .signed_duration_since(start_time) + .num_milliseconds() as f32; + let elapsed_time_s = elapsed_time_ms / 1000.0; + let frame_index = (elapsed_time_s * self.texture_metadata.fps) as usize; + frame_index % self.texture_metadata.frames.len() + }) + } + + /// Render the animation based on timestamp + pub fn render_automatic( + &mut self, + draw_handle: &mut RaylibDrawHandle, + position: Vector2, + percent_scale: Option>, + origin: Option>, + rotation: Option, + tint: Option, + ) { + // If this is the first time we're rendering, set the start time + if self.start_time.is_none() { + self.start_time = Some(Utc::now()); + } + + // Get the current frame index + let current_frame_index = self.get_current_frame_index(); + + // If we have a valid index, render it + if let Some(current_frame_index) = current_frame_index { + self.render_frame_by_index( + draw_handle, + current_frame_index, + position, + percent_scale, + origin, + rotation, + tint, + ); + } else { + warn!("We somehow got a frame index of None"); + } + } +} diff --git a/game/game_logic/src/rendering/utilities/mod.rs b/game/game_logic/src/rendering/utilities/mod.rs index e69de29b..09d38ecf 100644 --- a/game/game_logic/src/rendering/utilities/mod.rs +++ b/game/game_logic/src/rendering/utilities/mod.rs @@ -0,0 +1 @@ +pub mod anim_texture; \ No newline at end of file diff --git a/game/game_logic/src/scenes/mod.rs b/game/game_logic/src/scenes/mod.rs new file mode 100644 index 00000000..6fcbb5c7 --- /dev/null +++ b/game/game_logic/src/scenes/mod.rs @@ -0,0 +1,50 @@ +//! The render code for various scenes +//! +//! ## Overview +//! +//! This will probably become a messy module over time. Stick your rendering code here +use raylib::prelude::*; + +use crate::{discord::DiscordChannel, global_resource_package::GlobalResources}; + +use self::test_fox::TestFoxScene; +mod test_fox; + +/// Delegate for handling rendering. +/// This is a struct to allow for stateful data (like sub-screens) to be set up +pub struct SceneRenderDelegate { + /* Scenes */ + scene_test_fox: TestFoxScene, +} + +impl SceneRenderDelegate { + /// This is called when the game first loads + pub fn on_game_start(raylib: &mut RaylibHandle, rl_thread: &RaylibThread) -> Self { + // TODO: Stick any init code you want here. + + // Init some scenes + let scene_test_fox = TestFoxScene::new(raylib, rl_thread); + + Self { scene_test_fox } + } + + /// This is called every frame once the game has started. + /// + /// Keep in mind everything you do here will block the main thread (no loading files plz) + pub fn process_ingame_frame( + &mut self, + raylib: &mut RaylibHandle, + rl_thread: &RaylibThread, + discord: &DiscordChannel, + global_resources: &GlobalResources, + ) { + // For now, we will just render the test fox scene + self.scene_test_fox + .render_frame(raylib, rl_thread, &discord, global_resources); + } +} + +impl Drop for SceneRenderDelegate { + /// If you need anything to happen when the game closes, stick it here. + fn drop(&mut self) {} +} diff --git a/game/game_logic/src/scenes/test_fox.rs b/game/game_logic/src/scenes/test_fox.rs new file mode 100644 index 00000000..526dc57e --- /dev/null +++ b/game/game_logic/src/scenes/test_fox.rs @@ -0,0 +1,50 @@ +//! This "scene" is used only for testing animation and resource loading +//! It should be removed once the game is being worked on + +use raylib::prelude::*; +use nalgebra as na; + +use crate::{ + discord::DiscordChannel, global_resource_package::GlobalResources, + rendering::utilities::anim_texture::AnimatedTexture, +}; + +#[derive(Debug)] +pub struct TestFoxScene { + fox_animation: AnimatedTexture, +} + +impl TestFoxScene { + /// Construct a new `TestFoxScene` + pub fn new(raylib_handle: &mut RaylibHandle, thread: &RaylibThread) -> Self { + // Load the fox texture + let fox = AnimatedTexture::new(raylib_handle, thread, "chr", "testFox").unwrap(); + + Self { fox_animation: fox } + } + + /// Handler for each frame + pub fn render_frame( + &mut self, + raylib: &mut RaylibHandle, + rl_thread: &RaylibThread, + discord: &DiscordChannel, + global_resources: &GlobalResources, + ) { + // Get a drawing handle + let mut draw = raylib.begin_drawing(rl_thread); + + // Clear the screen + draw.clear_background(Color::WHITE); + + // Render the fox + self.fox_animation.render_automatic( + &mut draw, + na::Vector2::new(0.0, 0.0), + None, + None, + None, + None, + ); + } +} diff --git a/launch_anim_stitcher.sh b/launch_anim_stitcher.sh new file mode 100755 index 00000000..8d4785a9 --- /dev/null +++ b/launch_anim_stitcher.sh @@ -0,0 +1,6 @@ +#! /bin/bash +set -ex + +export PYTHONPATH=$(pwd)/automation:$PYTHONPATH +export LD50_PROJECT_ROOT=$(pwd) +python3 -m anim_stitcher \ No newline at end of file diff --git a/plugins/.gitkeep b/plugins/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/renderdoc_settings.cap b/renderdoc_settings.cap new file mode 100644 index 00000000..309662ba --- /dev/null +++ b/renderdoc_settings.cap @@ -0,0 +1,27 @@ +{ + "rdocCaptureSettings": 1, + "settings": { + "autoStart": false, + "commandLine": "--verbose", + "environment": [ + ], + "executable": "/home/ewpratten/projects/ludum-dare-50/target/debug/desktop_wrapper", + "inject": false, + "numQueuedFrames": 0, + "options": { + "allowFullscreen": true, + "allowVSync": true, + "apiValidation": false, + "captureAllCmdLists": false, + "captureCallstacks": false, + "captureCallstacksOnlyDraws": false, + "debugOutputMute": true, + "delayForDebugger": 0, + "hookIntoChildren": false, + "refAllResources": false, + "verifyBufferAccess": false + }, + "queuedFrameCap": 0, + "workingDir": "/home/ewpratten/projects/ludum-dare-50" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..82d4ac7d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pillow +PySide2 +autopep8 \ No newline at end of file diff --git a/third_party/raylib-rs b/third_party/raylib-rs index 3aff1382..abae275a 160000 --- a/third_party/raylib-rs +++ b/third_party/raylib-rs @@ -1 +1 @@ -Subproject commit 3aff138276b374f5e07187a652a71d9eb59e97d1 +Subproject commit abae275a63ee527cfe16d35a7d00d7532426d5a5