diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..dfd2cb5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,157 @@ +# Sago Multi Scrambler Puzzle II - AI Coding Agent Instructions + +## Project Overview +C++ puzzle game using SDL2, featuring image scrambling puzzles with splitting, shuffling, and flipping mechanics. Uses a state-based architecture with PhysFS for virtual filesystem abstraction and Dear ImGui for UI. + +## Architecture + +### State Machine Pattern +All game screens inherit from `sago::GameStateInterface` (located in `src/sago/GameStateInterface.hpp`): +- `IsActive()` - Returns false to pop state from stack +- `Draw(SDL_Renderer*)` - Render to screen +- `ProcessInput(const SDL_Event&, bool& processed)` - Handle events +- `Update()` - Per-frame logic + +**Key States:** +- `MainGameState` - Main menu (minimal implementation) +- `CollectionListState` - Lists all available collections +- `CollectionPlayState` - Plays puzzles sequentially from a collection, tracks progress +- `ImageSelectState` - Browse and select images from a folder +- `PuzzleSingleImageState` - Core puzzle gameplay (shuffle, flip, solve detection) +- `PuzzlePieceEditorState` - Custom piece layout editor (WIP) +- `SagoTextureSelector` - Texture selector for editor mode (WIP) + +### Global State & Resource Management +`GlobalData` struct (in `src/globals.hpp`) is the singleton holding: +- `SDL_Renderer* screen` - Rendering target +- `sago::SagoSpriteHolder* spriteHolder` - Sprite cache +- `sago::SagoDataHolder* dataHolder` - Texture/resource cache +- Window dimensions (`xsize`/`ysize`), mouse state, flags + +Access via global `globalData` instance. Resources auto-reload when renderer recreates. + +### Sprite System +Sprites defined in JSON files at `data/sprites/*.sprite`: +```json +{ + "sprite_name": { + "texture": "texture_name", + "topx": 0, "topy": 0, + "height": 64, "width": 64, + "number_of_frames": 1, + "frame_time": 100 + } +} +``` +Load via `globalData.spriteHolder->GetSprite("sprite_name")`, draw with `.Draw(renderer, SDL_GetTicks(), x, y)`. + +**UI Convention:** Nine-patch rectangles use `ui_rect_white_*` and `ui_rect_yellow_*` sprites (n, s, e, w, ne, nw, se, sw, fill variants). See `DrawRectYellow()`/`DrawRectWhite()` helpers in `MainGameState.cpp`. + +## Build System & Development + +### Building +```bash +cmake . # Configure (requires SDL2, SDL2_image, SDL2_ttf, SDL2_mixer, SDL2_gfx, Boost, physfs, rhash) +make # Compile +``` +**No test suite** — zero test infrastructure (no CTest, GTest, etc.). + +GitHub Actions CI: Builds in Docker ([extra/docker/Dockerfile](../extra/docker/Dockerfile)) on every push. + +### Running +```bash +./sago_multi_scrambler_puzzle2 # Main menu +./sago_multi_scrambler_puzzle2 image.jpg # Direct puzzle +./sago_multi_scrambler_puzzle2 --collection fairy_tales # Collection +./sago_multi_scrambler_puzzle2 --folder /path/to/images # Custom folder +./sago_multi_scrambler_puzzle2 --editor # Editor (WIP) +./sago_multi_scrambler_puzzle2 --install-desktop-entry # Linux: install .desktop + icon +``` + +### File Structure +- `src/` - Main source (game states, utilities) +- `src/sago/` - Reusable library code (sprites, data holders, platform folders) +- `src/Libs/` - Embedded third-party (Dear ImGui, rapidjson) +- `src/editor/` - Editor-specific components +- `data/` - Game assets (sprites, textures, fonts, collections) +- `embedded_libs/PlatformFolders-4.2.0/` - Cross-platform paths library + +## Key Patterns & Conventions + +### Game Loop +Standard flow: `InitGame()` → `RunGameState(state)` → `UninitGame()` + +`RunGameState()` (in `src/sago_common.cpp`) handles: +1. Clear/draw background +2. ImGui frame setup +3. Call state's `Draw()` and `Update()` +4. Process SDL events via state's `ProcessInput()` +5. Render ImGui and present + +### PhysFS Virtual Filesystem +All assets loaded through PhysFS (not direct file I/O): +- **Call order matters:** `InitSagoFS()` must run **before** `InitGame()` — the window icon loads from PhysFS in `InitGame()` +- Mount point: `data/` directory at startup (`InitSagoFS()` in `src/sago_common.cpp`) +- Use `sago::GetFileContent(path)` for reading files (note: in `sago::` namespace) +- Paths relative to mount: `"sprites/background.sprite"`, not `"data/sprites/..."` +- Write directory: save folder (lowest priority in mount search order) + +### Save Files +Platform-specific paths via `sago::platform_folders`: +- Linux: `~/.local/share/sago_multi_scrambler_puzzle2/` +- Windows: `Documents/My Games/sago_multi_scrambler_puzzle2/` +- Function: `getPathToSaveFiles()` in `os.cpp` +- ImGui settings: `imgui.ini` in save folder + +### Dear ImGui Integration +- Initialized in `InitGame()` with docking enabled +- Settings manually loaded/saved (not auto-saved) +- Process events via `ImGui_ImplSDL2_ProcessEvent(&event)` in each state's `ProcessInput()` +- New frame setup in `RunGameState()` loop + +## Common Tasks + +### Adding a New Game State +1. Create header/cpp in `src/`, inherit from `sago::GameStateInterface` +2. Implement all virtual methods (IsActive, Draw, ProcessInput, Update) +3. Set `isActive = false` to exit state (pops from stack) +4. Call `RunGameState(yourState)` after `InitGame()` + +### Adding UI Elements +Use nine-patch helpers: `DrawRectYellow(renderer, x, y, height, width)` or `DrawRectWhite()`. These are duplicated across states - consider refactoring to shared utility if modifying. + +### Working with Images +`PuzzleSingleImageState` loads images via `SDL_image`, splits them into logical pieces (stored in `pieces_logical`), then creates physical pieces (`pieces_physical`) scaled to screen. Key methods: +- `LoadPictureFromFile()` - Load and create initial piece +- `SplitPiece()` / `SplitPieceVertical()` / `SplitPieceHorisontal()` - Recursively divide pieces +- `Shuffle()` - Randomize piece positions and rotations +- `ResizeImagePhysical()` - Recalculate on window resize + +### Adding New Collections +Place images in `data/collections//`. Collections need a `collection.json` with a `puzzles` array (fields: `image`, `title`, `description`, `flip_mode`, `rectangular_mode`). Add a `README.md` for documentation. Collections are auto-discovered at runtime. + +### Application Icon +- **Window icon**: `data/textures/app_icon.png` — 128×128 PNG loaded via PhysFS in `InitGame()` +- **Desktop icon**: `extra/sago-multi-scrambler-puzzle2.svg` — SVG installed to `~/.local/share/icons/hicolor/scalable/apps/` by `--install-desktop-entry` +- Rasterize SVG: `inkscape --export-type=png --export-width=128 --export-height=128 --export-filename=data/textures/app_icon.png extra/sago-multi-scrambler-puzzle2.svg` + +## Gotchas & Technical Notes + +- **PhysFS init order:** `InitSagoFS()` must be called before `InitGame()` — icon loading at window creation depends on it +- **`sago::` namespace:** `GetFileContent()`, `FileExists()`, `WriteFileContent()` are all in the `sago::` namespace (from `sago/SagoMisc.hpp`) +- **Duplicated Code:** `DrawRect()`/`DrawRectYellow()`/`DrawRectWhite()` exist in both `MainGameState.cpp` and `ImageSelectState.cpp` as static functions +- **Header Links:** Some headers reference `https://github.com/sago007/saland` (old project) instead of correct `sago_multi_scrambler_puzzle2` +- **Version Macros:** `GAMENAME` defined in two places: `globals.hpp` (with display name) and `version.h` (lowercase for save paths) +- **Mouse Cursor:** Native cursor disabled, custom sprite drawn via `globalData.mouse` +- **Resource Invalidation:** When window resizes, must call `dataHolder.invalidateAll()` to force texture reloads +- **ImGui Backends:** Uses `imgui_impl_sdl2.cpp` and `imgui_impl_sdlrenderer2.cpp` from ImGui's backends + +## Dependencies +External libraries required by CMake: +- **SDL2** (core, image, ttf, mixer, gfx) - Graphics/audio/input +- **Boost** (program_options) - Command-line parsing +- **PhysFS** - Virtual filesystem +- **rhash** - Hashing library (used for puzzle state tracking) +- **platform_folders** - Cross-platform paths (embedded in `embedded_libs/`) + +All other libraries (ImGui, rapidjson) vendored in `src/Libs/`. diff --git a/.github/prompts/add-collection.prompt.md b/.github/prompts/add-collection.prompt.md new file mode 100644 index 0000000..24e8e32 --- /dev/null +++ b/.github/prompts/add-collection.prompt.md @@ -0,0 +1,42 @@ +--- +description: "Scaffold a new puzzle collection: creates collection.json and README.md under data/collections//" +argument-hint: "Collection name (e.g. animals, landscapes)" +agent: "agent" +--- + +Scaffold a new puzzle collection for this game. The collection name is: **$ARGUMENTS** + +## What to do + +1. Derive the folder name: lowercase, underscores for spaces (e.g. "Fairy Tales" → `fairy_tales`). + +2. Create `data/collections//collection.json` using this exact schema — do not add extra fields: +```json +{ + "name": "", + "description": "", + "puzzles": [ + { + "image": "", + "title": "", + "description": "", + "flip_mode": false, + "rectangular_mode": false + } + ] +} +``` +- `image` must be a filename only (no path) — images live in the same folder as `collection.json` +- `flip_mode: true` enables piece-flipping gameplay; use `false` unless the user asks for it +- `rectangular_mode: true` forces rectangular (non-rotated) pieces; use `false` unless asked + +3. Create `data/collections//README.md` listing each image filename, its source URL (if known), and the image author/copyright. Follow the format used in [data/collections/fairy_tales/README.md](../../data/collections/fairy_tales/README.md). + +4. **Do not** copy any image files — only create the metadata files. Remind the user to place image files in `data/collections//` before running the game. + +5. After creating the files, print a short summary: folder path, number of puzzle entries scaffolded, and a reminder about adding the actual image files. + +## Notes +- Collections are auto-discovered at runtime — no code changes needed. +- See [.github/copilot-instructions.md](../copilot-instructions.md) → "Adding New Collections" for full context. +- If the user hasn't provided image filenames, create placeholder entries with `"image": "TODO.jpg"` and note they need to be filled in. diff --git a/.github/prompts/new-game-state.prompt.md b/.github/prompts/new-game-state.prompt.md new file mode 100644 index 0000000..493f595 --- /dev/null +++ b/.github/prompts/new-game-state.prompt.md @@ -0,0 +1,108 @@ +--- +description: "Scaffold a new game state: creates the .hpp and .cpp boilerplate for a sago::GameStateInterface subclass in src/" +argument-hint: "State class name (e.g. HighScoreState, OptionsState)" +agent: "agent" +--- + +Scaffold a new game state for this project. The class name is: **$ARGUMENTS** + +## What to do + +1. Derive the filename: class name as-is (e.g. `HighScoreState` → `HighScoreState.hpp` / `HighScoreState.cpp`). Files go in `src/`. + +2. Create `src/.hpp` using this exact pattern (based on existing states like [src/CollectionListState.hpp](../src/CollectionListState.hpp)): + +```cpp +/* +=========================================================================== + * Sago Multi Scrambler Puzzle +Copyright (C) 2022-2026 Poul Sander +...license header... +=========================================================================== +*/ + +#pragma once + +#include "sago/GameStateInterface.hpp" + +class : public sago::GameStateInterface { +public: + (); + (const & orig) = delete; + virtual ~(); + + bool IsActive() override; + void ProcessInput(const SDL_Event& event, bool& processed) override; + void Draw(SDL_Renderer* target) override; + void Update() override; + +private: + bool isActive = true; +}; +``` + +3. Create `src/.cpp` using this exact pattern (based on [src/CollectionListState.cpp](../src/CollectionListState.cpp)): + +```cpp +/* +=========================================================================== + * Sago Multi Scrambler Puzzle +Copyright (C) 2022-2026 Poul Sander +...license header... +=========================================================================== +*/ + +#include ".hpp" +#include "sago_common.hpp" +#include "globals.hpp" +#include "SagoImGui.hpp" + +::() { +} + +::~() { +} + +bool ::IsActive() { + return isActive; +} + +void ::ProcessInput(const SDL_Event& event, bool& processed) { + ImGui_ImplSDL2_ProcessEvent(&event); + if (event.type == SDL_KEYDOWN) { + if (event.key.keysym.sym == SDLK_ESCAPE) { + isActive = false; + processed = true; + } + } +} + +void ::Draw(SDL_Renderer* target) { + // TODO: implement drawing +} + +void ::Update() { + // TODO: implement per-frame logic +} +``` + +4. **Do not** wire the state into `src/sago_multi_scrambler_puzzle2.cpp` unless the user explicitly asks — just create the two files. + +5. After creating the files, print: + - The two file paths created + - A snippet showing how to launch the state from `main()`: + ```cpp + #include ".hpp" + // ... + InitGame(); + state; + RunGameState(state); + UninitGame(); + ``` + +## Key rules +- Always `#include "SagoImGui.hpp"` in the .cpp so `ImGui_ImplSDL2_ProcessEvent` is available — forgetting this is the most common compile error. +- Use tabs for indentation (matches the existing codebase style). +- `isActive = false` is the only way to exit a state (pops from the game loop in `RunGameState()`). +- Access renderer output size via `globalData.xsize` / `globalData.ysize` (not SDL_GetWindowSize). +- See [.github/copilot-instructions.md](../copilot-instructions.md) → "Adding a New Game State" for full context. diff --git a/data/textures/app_icon.png b/data/textures/app_icon.png new file mode 100644 index 0000000..c6fcbaa Binary files /dev/null and b/data/textures/app_icon.png differ diff --git a/extra/sago-multi-scrambler-puzzle2.desktop b/extra/sago-multi-scrambler-puzzle2.desktop new file mode 100644 index 0000000..7573427 --- /dev/null +++ b/extra/sago-multi-scrambler-puzzle2.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Sago Multi Scrambler Puzzle II +Comment=Image scrambling puzzle game +Exec=/home/poul/programmering/git/sago_multi_scrambler_puzzle2/sago_multi_scrambler_puzzle2 %f +Icon=sago-multi-scrambler-puzzle2 +Terminal=false +Categories=Game;LogicGame; +MimeType=image/jpeg;image/png;image/jpg; \ No newline at end of file diff --git a/extra/sago-multi-scrambler-puzzle2.png b/extra/sago-multi-scrambler-puzzle2.png new file mode 100644 index 0000000..d07aa6b Binary files /dev/null and b/extra/sago-multi-scrambler-puzzle2.png differ diff --git a/extra/sago-multi-scrambler-puzzle2.svg b/extra/sago-multi-scrambler-puzzle2.svg new file mode 100644 index 0000000..8cc0096 --- /dev/null +++ b/extra/sago-multi-scrambler-puzzle2.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/sago_common.cpp b/src/sago_common.cpp index f64c84f..c7ef8bf 100644 --- a/src/sago_common.cpp +++ b/src/sago_common.cpp @@ -29,6 +29,7 @@ https://github.com/sago007/saland #include "sago_common.hpp" #include "SagoImGui.hpp" #include "sago/platform_folders.h" +#include "sago/SagoMisc.hpp" #include "os.hpp" #include @@ -167,6 +168,18 @@ void InitGame() { SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "2"); SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_SCALING, "1"); win = SDL_CreateWindow(GAMENAME, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, width, height, SDL_WINDOW_RESIZABLE); + // Set the window icon from the bundled PNG (loaded via PhysFS) + { + std::string icon_data = sago::GetFileContent("textures/app_icon.png"); + if (!icon_data.empty()) { + SDL_RWops* rw = SDL_RWFromConstMem(icon_data.data(), static_cast(icon_data.size())); + SDL_Surface* icon_surface = IMG_Load_RW(rw, 1); + if (icon_surface) { + SDL_SetWindowIcon(win, icon_surface); + SDL_FreeSurface(icon_surface); + } + } + } globalData.screen = SDL_CreateRenderer(win, -1, rendererFlags); //SDL_RenderSetLogicalSize(globalData.screen, width, height); InitImGui(win, globalData.screen, width, height); diff --git a/src/sago_multi_scrambler_puzzle2.cpp b/src/sago_multi_scrambler_puzzle2.cpp index 4997372..87d5208 100644 --- a/src/sago_multi_scrambler_puzzle2.cpp +++ b/src/sago_multi_scrambler_puzzle2.cpp @@ -119,12 +119,34 @@ bool installDesktopEntry() { desktop_file << "Name=Sago Multi Scrambler Puzzle II\n"; desktop_file << "Comment=Image scrambling puzzle game\n"; desktop_file << "Exec=" << exe_path << " %f\n"; + desktop_file << "Icon=sago-multi-scrambler-puzzle2\n"; desktop_file << "Terminal=false\n"; desktop_file << "Categories=Game;LogicGame;\n"; desktop_file << "MimeType=image/jpeg;image/png;image/jpg;\n"; desktop_file.close(); + // Install SVG icon into the hicolor icon theme + std::string svg_source; + try { + std::filesystem::path exe_dir = std::filesystem::path(exe_path).parent_path(); + std::filesystem::path svg_src = exe_dir / "extra" / "sago-multi-scrambler-puzzle2.svg"; + if (std::filesystem::exists(svg_src)) { + std::string icon_dir = sago::getDataHome() + "/icons/hicolor/scalable/apps"; + OsCreateFolder(icon_dir); + std::filesystem::path icon_dest = icon_dir + "/sago-multi-scrambler-puzzle2.svg"; + std::filesystem::copy_file(svg_src, icon_dest, std::filesystem::copy_options::overwrite_existing); + std::cout << "Icon installed at: " << icon_dest.string() << "\n"; + // Update icon cache if possible + std::string icon_cache_cmd = "gtk-update-icon-cache -f \"" + sago::getDataHome() + "/icons/hicolor\" 2>/dev/null"; + system(icon_cache_cmd.c_str()); + } else { + std::cout << "Note: SVG icon not found at " << svg_src.string() << ". Desktop entry will use fallback icon.\n"; + } + } catch (const std::exception& e) { + std::cout << "Note: Could not install icon: " << e.what() << "\n"; + } + std::cout << "Desktop entry created successfully at: " << desktop_file_path << "\n"; // Update the desktop database