Storage Demo

storage_demo.py
"""
Storage Demo – Multi-Character Save/Load with Query System
Shows:
- Multiple characters (id, name, char_class, level, hp, max_hp, exp, inventory)
- Game settings (volume, resolution, fullscreen)
- Export/import to JSON for machine migration
- Query filters and search
"""

import sys
import os
import random
import json
from pathlib import Path

sys.path.append(os.path.join(os.path.dirname(__file__), '..'))

from lunaengine.core import Scene, LunaEngine
from lunaengine.ui import *
from lunaengine.ui.tween import Tween, EasingType
from lunaengine.storage import Savedata, Table, Query, load_savedata, save_savedata

# ------------------------------------------------------------
# Demo Scene
# ------------------------------------------------------------

class StorageDemoScene(Scene):
    def __init__(self, engine: LunaEngine):
        super().__init__(engine)
        self.engine = engine

        # Storage
        self.savedata = Savedata()
        self.characters_table = None
        self.settings_table = None
        self.load_or_create_data()

        # UI state
        self.selected_char_id = None
        self.current_view = "list"  # list, edit, new
        self.edit_data = {}  # temporary for new/edit

        # Flag to prevent recursive slider updates
        self._updating_settings = False

        # Build UI
        self.setup_ui()
        self.refresh_character_list()
        self.update_settings_display()

    # ------------------------------------------------------------
    # Data Management
    # ------------------------------------------------------------
    def load_or_create_data(self):
        """Try to load from file, or create default data."""
        filepath = Path("game_data.sav")
        if filepath.exists():
            try:
                self.savedata = load_savedata(filepath, encryption_key="my_secret_key")
                print("Loaded savedata from game_data.sav")
            except Exception as e:
                print(f"Error loading: {e}, creating fresh data")
                self.create_default_data()
        else:
            self.create_default_data()

        self.characters_table = self.savedata.table("characters")
        self.settings_table = self.savedata.table("settings")

    def create_default_data(self):
        """Create tables with default characters and settings."""
        self.savedata = Savedata()

        # Characters table – column name is 'char_class'
        chars = self.savedata.create_table("characters", 
                                           columns=["id", "name", "char_class", "level", "hp", "max_hp", "exp", "inventory"],
                                           primary_key="id")
        sample_inventory = ["Sword", "Shield", "Potion"]
        chars.insert(id=1, name="Aragorn", char_class="Warrior", level=5, hp=45, max_hp=50, exp=120, inventory=sample_inventory)
        chars.insert(id=2, name="Legolas", char_class="Archer", level=4, hp=30, max_hp=35, exp=95, inventory=["Bow", "Arrows"])
        chars.insert(id=3, name="Gandalf", char_class="Wizard", level=6, hp=40, max_hp=40, exp=210, inventory=["Staff", "Spellbook"])
        chars.insert(id=4, name="Frodo", char_class="Rogue", level=3, hp=25, max_hp=28, exp=70, inventory=["Ring", "Dagger"])

        # Settings table
        settings = self.savedata.create_table("settings", columns=["key", "value"], primary_key="key")
        settings.insert(key="master_volume", value=0.8)
        settings.insert(key="music_volume", value=0.6)
        settings.insert(key="sfx_volume", value=0.9)
        settings.insert(key="fullscreen", value=False)
        settings.insert(key="resolution", value="1920x1080")

        self.save_data()

    def save_data(self):
        """Save to encrypted file."""
        self.savedata.save("game_data.sav", encryption_key="my_secret_key", compress=True)
        print("Data saved.")

    def refresh_tables(self):
        """Re-fetch table references after load/create."""
        self.characters_table = self.savedata.table("characters")
        self.settings_table = self.savedata.table("settings")

    # ------------------------------------------------------------
    # UI Setup
    # ------------------------------------------------------------
    def setup_ui(self):
        self.engine.set_global_theme(ThemeType.GRUVBOX)

        # Title
        title = TextLabel(512, 30, "Storage Demo – Character Manager", 36, pivot=(0.5, 0))
        self.add_ui_element(title)

        # Main tabs
        self.main_tabs = Tabination(25, 80, 980, 650, 20)
        self.main_tabs.add_tab("Characters")
        self.main_tabs.add_tab("Settings")
        self.main_tabs.add_tab("Migration")

        self.setup_characters_tab()
        self.setup_settings_tab()
        self.setup_migration_tab()

        self.add_ui_element(self.main_tabs)

        # FPS
        self.fps_label = TextLabel(self.engine.width - 10, 10, "FPS: --", 14, (100, 255, 100), pivot=(1, 0))
        self.add_ui_element(self.fps_label)

    # ---------- Characters Tab ----------
    def setup_characters_tab(self):
        tab = "Characters"
        y = 10

        # Buttons: New, Delete, Refresh, Save
        new_btn = Button(10, y, 100, 30, "New Character")
        new_btn.set_on_click(lambda: self.start_new_character())
        self.main_tabs.add_to_tab(tab, new_btn)

        delete_btn = Button(120, y, 100, 30, "Delete Selected")
        delete_btn.set_on_click(lambda: self.delete_selected_character())
        self.main_tabs.add_to_tab(tab, delete_btn)

        refresh_btn = Button(230, y, 100, 30, "Refresh")
        refresh_btn.set_on_click(lambda: self.refresh_character_list())
        self.main_tabs.add_to_tab(tab, refresh_btn)

        save_btn = Button(340, y, 100, 30, "Save")
        save_btn.set_on_click(lambda: self.save_data())
        self.main_tabs.add_to_tab(tab, save_btn)

        # Search bar
        self.main_tabs.add_to_tab(tab, TextLabel(460, y+5, "Search:", 16))
        self.search_input = TextBox(520, y, 150, 30, "")
        self.search_input.on_text_changed = lambda t: self.refresh_character_list(search_term=t)
        self.main_tabs.add_to_tab(tab, self.search_input)

        # Scrolling frame for character list
        self.char_list_frame = ScrollingFrame(10, y+50, 600, 500, 580, 800)
        self.main_tabs.add_to_tab(tab, self.char_list_frame)

        # Details panel
        detail_frame = UiFrame(630, y+50, 330, 500)
        detail_frame.set_background_color((40, 40, 60, 200))
        detail_frame.set_border((80, 80, 120), 2)
        detail_frame.set_corner_radius(8)
        self.main_tabs.add_to_tab(tab, detail_frame)

        self.detail_title = TextLabel(10, 10, "Character Details", 18, (255, 255, 0))
        detail_frame.add_child(self.detail_title)

        self.detail_labels = {}
        fields = ["name", "char_class", "level", "hp", "max_hp", "exp", "inventory"]
        display_names = ["Name", "Class", "Level", "HP", "Max HP", "EXP", "Inventory"]
        for i, (field, display) in enumerate(zip(fields, display_names)):
            lbl = TextLabel(10, 40 + i*25, f"{display}: --", 14, (200, 200, 200))
            detail_frame.add_child(lbl)
            self.detail_labels[field] = lbl

        self.detail_save_btn = Button(10, 240, 100, 30, "Save Changes")
        self.detail_save_btn.set_on_click(lambda: self.save_character_changes())
        detail_frame.add_child(self.detail_save_btn)

        self.detail_edit_btn = Button(120, 240, 100, 30, "Edit")
        self.detail_edit_btn.set_on_click(lambda: self.start_edit_character())
        detail_frame.add_child(self.detail_edit_btn)

        self.detail_edit_fields_frame = UiFrame(10, 280, 300, 200)
        detail_frame.add_child(self.detail_edit_fields_frame)
        self.detail_edit_fields_frame.visible = False

        self.edit_widgets = {}

    def refresh_character_list(self, search_term=None):
        """Populate the character list from the table, with optional search."""
        # Clear previous list
        for child in self.char_list_frame.children.copy():
            self.char_list_frame.remove_child(child)

        if self.characters_table is None:
            return

        # Build query
        query = self.characters_table.query()
        if search_term and search_term.strip():
            query.search(search_term.strip(), columns=["name", "char_class"], mode="contains", case_sensitive=False)

        # Execute and order by level descending
        rows = query.order_by("level", desc=True).execute()

        y = 10
        for row in rows:
            char_id = row["id"]
            name = row["name"]
            cls = row.get("char_class", "Unknown")
            level = row.get("level", 1)
            hp = row.get("hp", 0)
            max_hp = row.get("max_hp", 1)

            # Row frame
            row_frame = UiFrame(10, y, 560, 40)
            row_frame.set_background_color((50, 50, 70, 200) if y % 80 == 10 else (40, 40, 60, 200))
            row_frame.set_border((100, 100, 150), 1)
            row_frame.set_corner_radius(5)

            # Name + class + level
            name_lbl = TextLabel(10, 5, f"{name} ({cls})", 16, (255, 255, 255))
            row_frame.add_child(name_lbl)

            level_lbl = TextLabel(200, 5, f"Lv.{level}", 16, (200, 200, 255))
            row_frame.add_child(level_lbl)

            # HP bar
            hp_bar = ProgressBar(300, 10, 150, 20, 0, max_hp, hp, style='soundpad')
            row_frame.add_child(hp_bar)

            hp_text = TextLabel(460, 10, f"{hp}/{max_hp}", 12, (200, 200, 200))
            row_frame.add_child(hp_text)

            # Select button
            select_btn = Button(520, 5, 30, 30, "â–¶")
            select_btn.set_on_click(lambda cid=char_id: self.select_character(cid))
            row_frame.add_child(select_btn)

            self.char_list_frame.add_child(row_frame)
            y += 45

        self.char_list_frame.total_height = max(200, y + 20)

    def select_character(self, char_id):
        """Show character details."""
        self.selected_char_id = char_id
        row = self.characters_table.get_by_primary_key(char_id)
        if not row:
            return

        # Update detail labels
        for field, lbl in self.detail_labels.items():
            val = row.get(field, "--")
            if field == "inventory" and isinstance(val, list):
                val = ", ".join(val)
            # Update only the value part
            current_text = lbl.text
            if ':' in current_text:
                prefix = current_text.split(':')[0] + ':'
                lbl.set_text(f"{prefix} {val}")
            else:
                lbl.set_text(f"{field}: {val}")

        self.detail_edit_btn.enabled = True

    def start_new_character(self):
        """Switch to new character form."""
        self.current_view = "new"
        self.edit_data = {
            "name": "",
            "char_class": "Warrior",
            "level": 1,
            "hp": 30,
            "max_hp": 30,
            "exp": 0,
            "inventory": []
        }
        self.show_edit_form("Create New Character", save_callback=self.create_new_character)

    def start_edit_character(self):
        """Switch to edit mode for selected character."""
        if self.selected_char_id is None:
            return
        row = self.characters_table.get_by_primary_key(self.selected_char_id)
        if not row:
            return
        self.current_view = "edit"
        self.edit_data = row.copy()
        self.show_edit_form("Edit Character", save_callback=self.save_character_changes)

    def show_edit_form(self, title, save_callback):
        """Create/edit form overlay."""
        self.detail_edit_fields_frame.visible = True
        # Clear previous widgets
        for w in self.edit_widgets.values():
            self.detail_edit_fields_frame.remove_child(w)
        self.edit_widgets.clear()

        y = 10
        for field, value in self.edit_data.items():
            if field == "id":
                continue
            display_name = field.replace('_', ' ').title()
            lbl = TextLabel(10, y, f"{display_name}:", 14)
            self.detail_edit_fields_frame.add_child(lbl)

            if field == "inventory":
                inp = TextBox(120, y-5, 150, 25, ", ".join(value) if isinstance(value, list) else str(value))
                inp.on_text_changed = lambda t, f=field: self.update_edit_field(f, t)
            elif isinstance(value, (int, float)):
                inp = NumberSelector(120, y-5, 80, 25, 0, 999, int(value))
                inp.on_value_changed = lambda v, f=field: self.update_edit_field(f, v)
            else:
                inp = TextBox(120, y-5, 150, 25, str(value))
                inp.on_text_changed = lambda t, f=field: self.update_edit_field(f, t)

            self.detail_edit_fields_frame.add_child(inp)
            self.edit_widgets[field] = inp
            y += 30

        # Save button
        save_btn = Button(10, y+10, 100, 30, "Save")
        save_btn.set_on_click(save_callback)
        self.detail_edit_fields_frame.add_child(save_btn)

        cancel_btn = Button(120, y+10, 100, 30, "Cancel")
        cancel_btn.set_on_click(lambda: self.cancel_edit())
        self.detail_edit_fields_frame.add_child(cancel_btn)

        self.detail_edit_fields_frame.height = y + 50

    def update_edit_field(self, field, value):
        self.edit_data[field] = value

    def create_new_character(self):
        """Insert new character from edit_data."""
        if not self.edit_data.get("name"):
            self.engine.show_notification("Name is required!", NotificationType.WARNING)
            return
        max_id = 0
        for row in self.characters_table.rows:
            if row.get("id", 0) > max_id:
                max_id = row["id"]
        new_id = max_id + 1
        self.edit_data["id"] = new_id
        inv = self.edit_data.get("inventory", "")
        if isinstance(inv, str):
            inv = [item.strip() for item in inv.split(",") if item.strip()]
        self.edit_data["inventory"] = inv

        self.characters_table.insert(**self.edit_data)
        self.save_data()
        self.cancel_edit()
        self.refresh_character_list()
        self.engine.show_notification(f"Character '{self.edit_data['name']}' created!", NotificationType.SUCCESS)

    def save_character_changes(self):
        """Update selected character with edit_data."""
        if self.selected_char_id is None:
            return
        inv = self.edit_data.get("inventory", "")
        if isinstance(inv, str):
            inv = [item.strip() for item in inv.split(",") if item.strip()]
        self.edit_data["inventory"] = inv

        row = self.characters_table.get_by_primary_key(self.selected_char_id)
        if row:
            for key, val in self.edit_data.items():
                if key != "id":
                    row[key] = val
            self.save_data()
            self.cancel_edit()
            self.refresh_character_list()
            self.select_character(self.selected_char_id)
            self.engine.show_notification("Character updated!", NotificationType.SUCCESS)

    def cancel_edit(self):
        self.detail_edit_fields_frame.visible = False
        self.current_view = "list"

    def delete_selected_character(self):
        if self.selected_char_id is None:
            return
        row = self.characters_table.get_by_primary_key(self.selected_char_id)
        if row:
            name = row.get("name", "Unknown")
            self.characters_table.delete(lambda r: r["id"] == self.selected_char_id)
            self.save_data()
            self.selected_char_id = None
            self.detail_title.set_text("Character Details")
            for lbl in self.detail_labels.values():
                current_text = lbl.text
                if ':' in current_text:
                    prefix = current_text.split(':')[0] + ':'
                    lbl.set_text(f"{prefix} --")
                else:
                    lbl.set_text(f"{lbl.text.split(':')[0]}: --")
            self.refresh_character_list()
            self.engine.show_notification(f"Deleted '{name}'", NotificationType.INFO)

    # ---------- Settings Tab ----------
    def setup_settings_tab(self):
        tab = "Settings"
        y = 10

        self.main_tabs.add_to_tab(tab, TextLabel(10, y, "Game Settings", 24, (255, 255, 0)))

        # Master volume
        self.main_tabs.add_to_tab(tab, TextLabel(10, y+50, "Master Volume:", 16))
        self.master_vol_slider = Slider(160, y+45, 200, 20, 0, 1, 0.8)
        # Use a wrapper to prevent recursive loop
        self.master_vol_slider.on_value_changed = lambda v: self._on_setting_changed("master_volume", v)
        self.main_tabs.add_to_tab(tab, self.master_vol_slider)
        self.master_vol_label = TextLabel(370, y+50, "0.80", 14)
        self.main_tabs.add_to_tab(tab, self.master_vol_label)

        # Music volume
        self.main_tabs.add_to_tab(tab, TextLabel(10, y+80, "Music Volume:", 16))
        self.music_vol_slider = Slider(160, y+75, 200, 20, 0, 1, 0.6)
        self.music_vol_slider.on_value_changed = lambda v: self._on_setting_changed("music_volume", v)
        self.main_tabs.add_to_tab(tab, self.music_vol_slider)
        self.music_vol_label = TextLabel(370, y+80, "0.60", 14)
        self.main_tabs.add_to_tab(tab, self.music_vol_label)

        # SFX volume
        self.main_tabs.add_to_tab(tab, TextLabel(10, y+110, "SFX Volume:", 16))
        self.sfx_vol_slider = Slider(160, y+105, 200, 20, 0, 1, 0.9)
        self.sfx_vol_slider.on_value_changed = lambda v: self._on_setting_changed("sfx_volume", v)
        self.main_tabs.add_to_tab(tab, self.sfx_vol_slider)
        self.sfx_vol_label = TextLabel(370, y+110, "0.90", 14)
        self.main_tabs.add_to_tab(tab, self.sfx_vol_label)

        # Fullscreen switch
        self.main_tabs.add_to_tab(tab, TextLabel(10, y+140, "Fullscreen:", 16))
        self.fullscreen_switch = Switch(160, y+135, 60, 30, False)
        self.fullscreen_switch.set_on_toggle(lambda s: self._on_setting_changed("fullscreen", s))
        self.main_tabs.add_to_tab(tab, self.fullscreen_switch)

        # Resolution dropdown
        self.main_tabs.add_to_tab(tab, TextLabel(10, y+180, "Resolution:", 16))
        self.res_dropdown = Dropdown(160, y+175, 150, 30, ["1920x1080", "1280x720", "1024x768"])
        self.res_dropdown.set_on_selection_changed(lambda i, v: self._on_setting_changed("resolution", v))
        self.main_tabs.add_to_tab(tab, self.res_dropdown)

        # Save settings button
        save_sett_btn = Button(10, y+230, 150, 30, "Save Settings")
        save_sett_btn.set_on_click(lambda: self.save_data())
        self.main_tabs.add_to_tab(tab, save_sett_btn)

        # Load defaults button
        load_def_btn = Button(170, y+230, 150, 30, "Load Defaults")
        load_def_btn.set_on_click(lambda: self.reset_settings())
        self.main_tabs.add_to_tab(tab, load_def_btn)

    def _on_setting_changed(self, key, value):
        """Handle setting change without recursion."""
        if self._updating_settings:
            return
        self.update_setting(key, value)

    def update_setting(self, key, value):
        """Update a setting in the settings table."""
        if self.settings_table is None:
            return
        row = self.settings_table.get_by_primary_key(key)
        if row:
            row["value"] = value
        else:
            self.settings_table.insert(key=key, value=value)
        self.save_data()
        self.update_settings_display()

    def update_settings_display(self):
        """Refresh UI sliders and labels from current settings."""
        if self.settings_table is None:
            return
        self._updating_settings = True
        for row in self.settings_table.rows:
            key = row["key"]
            val = row["value"]
            if key == "master_volume":
                self.master_vol_slider.set_value(val)
                self.master_vol_label.set_text(f"{val:.2f}")
            elif key == "music_volume":
                self.music_vol_slider.set_value(val)
                self.music_vol_label.set_text(f"{val:.2f}")
            elif key == "sfx_volume":
                self.sfx_vol_slider.set_value(val)
                self.sfx_vol_label.set_text(f"{val:.2f}")
            elif key == "fullscreen":
                self.fullscreen_switch.set_value(val)
            elif key == "resolution":
                try:
                    idx = ["1920x1080", "1280x720", "1024x768"].index(val)
                    self.res_dropdown.set_selected_index(idx)
                except ValueError:
                    pass
        self._updating_settings = False

    def reset_settings(self):
        """Reset settings to defaults and save."""
        defaults = {
            "master_volume": 0.8,
            "music_volume": 0.6,
            "sfx_volume": 0.9,
            "fullscreen": False,
            "resolution": "1920x1080"
        }
        for key, val in defaults.items():
            row = self.settings_table.get_by_primary_key(key)
            if row:
                row["value"] = val
            else:
                self.settings_table.insert(key=key, value=val)
        self.save_data()
        self.update_settings_display()
        self.engine.show_notification("Settings reset to defaults", NotificationType.INFO)

    # ---------- Migration Tab ----------
    def setup_migration_tab(self):
        tab = "Migration"
        y = 10

        self.main_tabs.add_to_tab(tab, TextLabel(10, y, "Machine Migration (Export/Import)", 24, (255, 255, 0)))
        y += 50

        info = ("Export your data to a plain JSON file (no encryption). "
                "You can transfer this file to another machine and import it.")
        info_lbl = LongTextLabel(10, y, info, width=500, height=60, font_size=14)
        self.main_tabs.add_to_tab(tab, info_lbl)
        y += 80

        # Export button
        export_btn = Button(10, y, 150, 30, "Export to JSON")
        export_btn.set_on_click(lambda: self.export_data())
        self.main_tabs.add_to_tab(tab, export_btn)

        # Import button
        import_btn = Button(170, y, 150, 30, "Import from JSON")
        import_btn.set_on_click(lambda: self.import_data())
        self.main_tabs.add_to_tab(tab, import_btn)

        y += 50
        self.migration_status = TextLabel(10, y, "Ready", 14, (200, 200, 200))
        self.main_tabs.add_to_tab(tab, self.migration_status)

        # Additional info about encryption
        y += 40
        info2 = ("Note: The game saves encrypted with a key. "
                 "Exporting creates a plain file; import will load it and re-encrypt with current key.")
        info2_lbl = LongTextLabel(10, y, info2, width=500, height=60, font_size=12, color=(150, 150, 200))
        self.main_tabs.add_to_tab(tab, info2_lbl)

    def export_data(self):
        """Export all data to a JSON file."""
        try:
            self.savedata.export_to_json("export_data.json")
            self.migration_status.set_text("Exported to export_data.json")
            self.engine.show_notification("Data exported successfully!", NotificationType.SUCCESS)
        except Exception as e:
            self.migration_status.set_text(f"Export failed: {e}")
            self.engine.show_notification(f"Export error: {e}", NotificationType.ERROR)

    def import_data(self):
        """Import from JSON file and re-save with encryption."""
        try:
            self.savedata.import_from_json("export_data.json")
            # Re-save with encryption
            self.savedata.save("game_data.sav", encryption_key="my_secret_key", compress=True)
            self.refresh_tables()
            self.refresh_character_list()
            self.update_settings_display()
            self.migration_status.set_text("Imported from export_data.json and re-encrypted")
            self.engine.show_notification("Data imported successfully!", NotificationType.SUCCESS)
        except FileNotFoundError:
            self.migration_status.set_text("export_data.json not found!")
            self.engine.show_notification("File not found: export_data.json", NotificationType.ERROR)
        except Exception as e:
            self.migration_status.set_text(f"Import failed: {e}")
            self.engine.show_notification(f"Import error: {e}", NotificationType.ERROR)

    # ------------------------------------------------------------
    # Update & Render
    # ------------------------------------------------------------
    def update(self, dt):
        fps = self.engine.get_fps_stats()['current_fps']
        self.fps_label.set_text(f"FPS: {fps:.1f}")

    def render(self, renderer):
        renderer.fill_screen(ThemeManager.get_color('background'))
        renderer.draw_rect(0, 0, self.engine.width, 80, ThemeManager.get_color('background2'))

# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def main():
    engine = LunaEngine("Storage Demo", 1024, 768, debug=True, show_splash=False)
    engine.fps = 60
    engine.add_scene("StorageDemo", StorageDemoScene)
    engine.set_scene("StorageDemo")
    engine.run()

if __name__ == "__main__":
    main()
About This Example

Storage Demo – Multi-Character Save/Load with Query System