diff --git a/.gitignore b/.gitignore index 0a34381..95df8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.vs \ No newline at end of file +.vs +pygame test/pygame test/saves/ +**/__pycache__/ \ No newline at end of file diff --git a/pygame test/ROADMAP.md b/pygame test/ROADMAP.md new file mode 100644 index 0000000..29e10c7 --- /dev/null +++ b/pygame test/ROADMAP.md @@ -0,0 +1,61 @@ +# Light & Dark — Roadmap + +Destination: **polished vertical slice** — Regions 1–2, save system, skill upgrades. +Proves the core loop is fun before scaling to the full vision. + +## Design decisions (locked) + +- **Death penalty**: on death, drop light/dark coins at the death spot; reclaim them or lose them permanently on a second death. The fairy hovers over the spot as the marker. +- **Skill upgrades**: + - **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins. + - **Gems / rings** give alternate behaviors / sidegrades. +- **Ranged identity**: keep the bolt, add upgrades (no charge-up / homing rework). +- **Fairy (in slice)**: guide + ambient light source (bonus flow regen near player). Scout mode, teleport, fairy holes deferred to regions 3+. +- **Ascended armor**: cut for now. Awakened is good enough at the start; less balancing needed. + +## Milestones + +### M0 — Foundation (the game remembers) +| Item | Effort | Notes | +|---|---|---| +| ~~Save/persistence system~~ | L | **DONE (2026-09-28)**: JSON save to `saves/`, save via pause menu. Stores player state, room, coins, equipment, inventory, quests, tokens, boss diary, defeated bosses, broken doors. | +| Charge -> Flow rename | S | Code + UI + configs. | +| ~~Main menu~~ | M | **DONE (2026-09-28)**: Select Game (greyed with no saves), New Game (name entry), Settings (rebinding), Quit. Wipe-all-saves button (bottom right) with confirmation dialog. | + +### M1 — Region 1 complete loop +| Item | Effort | Notes | +|---|---|---| +| Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death) | M | Save system exists now; drop-position data needed. Ties into fairy. | +| Fairy companion (guide + ambient light source -> bonus flow regen) | M | Wakes player at start, tutorial dialogue. | +| More quests (repeatable + one-time; system exists) | S | Just content. | +| Lore dialogue for fairy / NPCs | S | Needs written copy. | + +### M2 — Skill upgrades +| Item | Effort | Notes | +|---|---|---| +| Vendor (smith/trainer) NPC | M | Reuses NPC + coin systems. | +| Vendor upgrades (linear): Dash = lower cooldown, Ranged = more damage, Tether = longer bind/fear | S | Data + UI. | +| Ring slot + gems (sidegrades): Dash = speed-stacks / refund-on-kill / afterimage taunt; Ranged = pierce (light) / aoe (shade) / kill-stacks; Tether = boss effects / higher threshold | L | New slot, new item type, build UI. | + +### M3 — Region 2: shade matters +| Item | Effort | Notes | +|---|---|---| +| Shade-gated interactions (doors, an NPC, an area) | S-M | Room JSON + existing toggle. | +| New enemy behaviors (patrol, dodge-and-leap, burrow-shooter) | M | Enemy system supports it. | +| Quick recharge pickup | S | Mid-battle refill. | +| Ability tweaks: ranged upgrades, ground-slam knockback/iframe, light-step buffs | M | Picks up remaining notes ideas. | +| Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). | + +### Stretch / core (post-slice, in order) +- Fast travel (rune stones) +- Fairy scout + bond teleport + fairy holes +- Regions 3-5 per pacing notes +- World-change weapon (stretch-end, decide variant) + +### Cut +- Ascended armor (removed). + +## Dependencies + +- Save is the keystone: death penalty, main menu, and any progress feature hang off it. +- Fairy and Flow rename can run in parallel with M0. \ No newline at end of file diff --git a/pygame test/notes.txt b/pygame test/notes.txt index 3215771..9008e81 100644 --- a/pygame test/notes.txt +++ b/pygame test/notes.txt @@ -38,8 +38,7 @@ Tether How to obtain skill upgrades - vendor (smith? trainer?) that can upgrade one spell (selection can be changed but requires paying for it every time) - item (gem?) that can be slotted into armor to unlock a skill upgrade - - +- ring to go with necklaces (no synergy to not force those even more like armor already does) @@ -47,4 +46,85 @@ Ascended armor - select one awakened armor piece as the main piece and one as the sacrifice - select X number of traits from the sacrifice to merge onto the main piece - sacrifice gets destroyed in the process, creating an Ascended armor piece - - cost: many coins/rare resource? \ No newline at end of file + - cost: many coins/rare resource? + + +Further ideas + +Quests: + Repeatable + - usually has a loot pool of items but the player also gains resources to buy stuff from this loot pool (as some sort of pity system) + One-time + - fixed reward + - essential or useful for progression + +Boss Tokens +- bosses drop an item (unique per boss) that can be "collected" (diary) to fight them again +- token can be brought to a specific NPC to upgrade it to fight harder bosses + +Rework for Charge -> Flow, butterflies (for lore) instead of Lanterns + +Regions + 1st region: Shade mode is a thing, but not widely used. Player gets used to FLOW. First easy quest. + 2nd region: Shade mode is needed for a handful of interactions, but not really for combat. First boss fight. First ability. Town 1. + 3rd region: Shade mode is required to access areas or NPCs. New boss which unlocks region 4, but is optional for now. + 4th region: Access through third area boss. Shade mode is required to traverse (unique environmental effects). Boss fight that has special targeting projectiles + if the player isn't in shade mode. New ability. Fast travel to 5th region/Town 2? + 5th region: Alternate to 4th region in terms of progress. Light area. Town 2. Quest board/NPCs/player housing (for accessing the diary) + + Ability changes (ideas) + Ranged: + - pierce + - less dmg for less flow cost + - faster/ramp up traveling speed/scale damage with speed + - more damage to specific enemies + - bigger but slower + - other directions (up/diagonal?) + - hold button to charge/release, first enemy hit will be marked for ? seconds, further shots will home in on marked enemy + + Ground slam shade: + - knockback + - dmg immunity when hitting 1+ enemies + + Light step: + - dmg up for ? seconds after/when touching platform + - speed increase after/when touching platform + +Companion (fairy) + - interaction/guide + - ability: fairy bond teleport? + +Fairy bond + - reinforce bond with fairy companion: allow fairy scout mode (fly and look but no interactions) and later on teleport to companion if it flies + ahead in this mode + - access to this bond mode takes a few seconds (channel) to not make the light step ability unneccessary + - fairy can make light at the expense of the player's flow + - fairy holes (environmental features/shortcuts) where player can teleport through with bond teleport + +Fast travel +- items found all over the map (rune stones) which can be slotted into special devices to link those together as fast travel points + +Enemies + design/behavior? + - slow patrol, move/jump in player direction + - slow patrol, doge backwards and leap at player + - stationary, shoot at player, burrow to somewhere else nearby + +Quick recharge +- player can collect something in advance to refill charge mid-battle with a quick press of a button + +Death +- what does the player lose? +- upgrades to lose less? + +Lore +- player character is a being of light and dark, created by a forbidden love between a light and dark being +- gods? -> forbidden child <- banished and sleeping -> woken up long later by fairy companion (this is where the game starts)? +- parents of player character sent companion to support and guide the player back to their parents +- flow/charge is not inheritently dark or light, it is simply a resource that exists in the player character and a few other things in the world + +World change +- mid game progress: unique weapon that the player can choose to take +- world changes if weapon is taken +- if weapon is not taken, player can still explore and return later to take the weapon unless they trigger a different event which would also change the world +(but differently compared to when they'd take the weapon) -> do they still get that weapon but in a different variant? \ No newline at end of file diff --git a/pygame test/pygame_test.py b/pygame test/pygame_test.py index f600ee0..2b3134d 100644 --- a/pygame test/pygame_test.py +++ b/pygame test/pygame_test.py @@ -2,6 +2,7 @@ import json import random from pathlib import Path +from datetime import datetime from copy import deepcopy import pygame import math @@ -4722,7 +4723,7 @@ def _draw_tether_chain_loop(surface, rect: pygame.Rect, light: bool, cx: float, def get_pause_menu_rects(): - items = ["Resume", "Settings", "Close Game"] + items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"] rects = [] start_y = HEIGHT // 2 + 20 for i, label in enumerate(items): @@ -4734,7 +4735,7 @@ def get_pause_menu_rects(): def draw_pause_menu(surface, selected_idx: int, use_mouse: bool): - items = ["Resume", "Settings", "Close Game"] + items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"] rects = get_pause_menu_rects() mouse_pos = pygame.mouse.get_pos() hover_idx = None @@ -5865,6 +5866,557 @@ def draw_stats_overlay(surface): ty += 18 +# ---------------------------- +# Save system +# ---------------------------- +SAVES_DIR = BASE_DIR / "saves" + + +def ensure_saves_dir(): + SAVES_DIR.mkdir(parents=True, exist_ok=True) + + +def sanitize_save_name(name: str) -> str: + out = [] + for ch in str(name): + if ch.isalnum() or ch in (" ", "-", "_"): + out.append(ch) + return "".join(out).strip()[:40] + + +def save_path(name: str) -> Path: + return SAVES_DIR / f"{sanitize_save_name(name)}.json" + + +def list_saves() -> list[dict]: + """Returns [{name, path, updated}] sorted newest-first.""" + ensure_saves_dir() + out = [] + for p in sorted(SAVES_DIR.glob("*.json"), key=lambda pp: pp.stat().st_mtime, reverse=True): + updated_str = "" + try: + updated_str = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + except OSError: + pass + out.append({"name": p.stem, "path": p, "updated": updated_str}) + return out + + +def save_game(name: str = "auto") -> str: + """Serialize the current game state to a save file. Returns the save name used.""" + ensure_saves_dir() + name = sanitize_save_name(name) or "auto" + data = { + "version": 1, + "saved_at": datetime.now().isoformat(timespec="seconds"), + "player": { + "room_id": ROOM.id, + "x": player.rect.centerx, + "y": player.rect.bottom, + "health": player.health, + "charge": round(float(player.charge), 2), + "shade": player.shade, + "coins_light": player.coins_light, + "coins_dark": player.coins_dark, + "has_ranged": player.has_ranged, + "reward_chest_opened": player.reward_chest_opened, + "equipment": {s: it for s, it in player.equipment.items()}, + }, + "inventory": inventory.items, + "quests": { + "active": quest_manager.active, + "completed_one_time": sorted(quest_manager.completed_one_time), + "tokens": quest_manager.tokens, + }, + "boss_diary": {"slotted": boss_diary.slotted}, + "defeated_bosses": sorted(DEFEATED_BOSSES), + "broken_doors": sorted(BROKEN_DOORS), + } + path = save_path(name) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + return name + + +def apply_save_data(data: dict): + """Apply a save's state to the current live game objects (player/inventory/quests/room).""" + pdata = data.get("player", {}) or {} + + # persistent player fields + player.coins_light = int(pdata.get("coins_light", 0)) + player.coins_dark = int(pdata.get("coins_dark", 0)) + player.has_ranged = bool(pdata.get("has_ranged", False)) + player.reward_chest_opened = bool(pdata.get("reward_chest_opened", False)) + + eq = pdata.get("equipment") or {} + player.equipment = {s: (dict(it) if it else None) for s, it in eq.items()} + player.recalc_bonuses() + player._clamp_resources() + + inventory.items = [dict(it) for it in (data.get("inventory") or [])] + + qdata = data.get("quests") or {} + quest_manager.active = {k: dict(v) for k, v in (qdata.get("active") or {}).items()} + quest_manager.completed_one_time = set(qdata.get("completed_one_time") or []) + quest_manager.tokens = int(qdata.get("tokens", 0)) + + bdata = data.get("boss_diary") or {} + boss_diary.slotted = {k: int(v) for k, v in (bdata.get("slotted") or {}).items()} + + DEFEATED_BOSSES.clear() + DEFEATED_BOSSES.update(data.get("defeated_bosses") or []) + BROKEN_DOORS.clear() + BROKEN_DOORS.update(data.get("broken_doors") or []) + + room_id = normalize_room_id(str(pdata.get("room_id") or "room_01.json")) + room = room_manager.load_room(room_id) + px, py = pdata.get("x"), pdata.get("y") + if isinstance(px, (int, float)) and isinstance(py, (int, float)): + room.player_spawn = [int(px), int(py)] + apply_room(room, set_player_spawn=True) + + player.respawn() + if "health" in pdata: + player.health = int(pdata["health"]) + if "charge" in pdata: + player.charge = float(pdata["charge"]) + player.shade = bool(pdata.get("shade", False)) + player._clamp_resources() + + +def fresh_save_data() -> dict: + return { + "version": 1, + "player": { + "room_id": "room_01.json", + "x": None, + "y": None, + "health": BASE_MAX_HEALTH, + "charge": BASE_MAX_CHARGE, + "shade": False, + "coins_light": 0, + "coins_dark": 0, + "has_ranged": False, + "reward_chest_opened": False, + "equipment": {}, + }, + "inventory": [], + "quests": {"active": {}, "completed_one_time": [], "tokens": 0}, + "boss_diary": {"slotted": {}}, + "defeated_bosses": [], + "broken_doors": [], + } + + +def load_game(name: str) -> bool: + path = save_path(name) + if not path.exists(): + return False + try: + data = load_json(path) + except Exception as e: + print(f"[Load failed] {path} -> {e}") + return False + apply_save_data(data) + return True + + +def start_new_game(name: str) -> str: + """Reset all state to a fresh game and save it. Returns the save name used.""" + room_id = "room_01.json" + room_json = load_json_safe(room_id, [ROOMS_DIR, LEVELS_DIR, DATA_ROOT, BASE_DIR], ROOM_DEFAULT) + room_json = normalize_room_json(room_json) + spawn = room_json.get("player_spawn", [200, 100]) + data = fresh_save_data() + if isinstance(spawn, (list, tuple)) and len(spawn) == 2: + data["player"]["x"] = int(spawn[0]) + data["player"]["y"] = int(spawn[1]) + apply_save_data(data) + + final_name = sanitize_save_name(name) or "auto" + base = final_name + i = 2 + while save_path(final_name).exists(): + final_name = f"{base} {i}" + i += 1 + return save_game(final_name) + + +def wipe_all_saves(): + ensure_saves_dir() + for p in SAVES_DIR.glob("*.json"): + try: + p.unlink() + except OSError: + pass + + +def _do_save_game(): + global CURRENT_SAVE_NAME, save_feedback, save_feedback_timer + name = CURRENT_SAVE_NAME or "auto" + CURRENT_SAVE_NAME = save_game(name) + save_feedback = f"Saved: {CURRENT_SAVE_NAME}" + save_feedback_timer = 2.0 + + +def _return_to_main_menu(): + global GAME_MODE, paused, pause_menu, pause_menu_index, pause_use_mouse + global settings_wait_action + if CURRENT_SAVE_NAME: + _do_save_game() + inv_ui.visible = False + trade_ui.visible = False + trade_ui.active_npc = None + diary_ui.visible = False + paused = False + pause_menu = "main" + pause_menu_index = 0 + pause_use_mouse = False + settings_wait_action = None + GAME_MODE = "menu" + + +# ---------------------------- +# Main menu +# ---------------------------- +GAME_MODE = "menu" # menu | select | newgame | settings | confirm_wipe | game + +MAIN_MENU_ITEMS = ["Select Game", "New Game", "Settings", "Quit"] + +menu_index = 0 +menu_use_mouse = False +select_index = 0 +select_use_mouse = False +newgame_name_input = "" +wipe_confirm_idx = 1 +wipe_use_mouse = False +CURRENT_SAVE_NAME: str | None = None +save_feedback = "" +save_feedback_timer = 0.0 + + +def get_main_menu_rects(): + rects = [] + start_y = int(HEIGHT * 0.38) + for label in MAIN_MENU_ITEMS: + text = font.render(label, True, (240, 240, 240)) + x = WIDTH // 2 - text.get_width() // 2 + y = start_y + rects.append(pygame.Rect(x - 8, y - 4, text.get_width() + 16, text.get_height() + 8)) + start_y += 36 + return rects + + +def get_wipe_button_rect() -> pygame.Rect: + w, h = 150, 26 + return pygame.Rect(WIDTH - w - 12, HEIGHT - h - 12, w, h) + + +def draw_main_menu(surface, selected_idx: int, use_mouse: bool, has_saves: bool): + title_font = pygame.font.SysFont(None, 72) + title = title_font.render("Light & Dark", True, (240, 235, 210)) + surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.14))) + sub = font.render("Prototype", True, (160, 160, 160)) + surface.blit(sub, (WIDTH // 2 - sub.get_width() // 2, int(HEIGHT * 0.14) + 68)) + + rects = get_main_menu_rects() + mouse_pos = pygame.mouse.get_pos() + hover_idx = None + for i, r in enumerate(rects): + if r.collidepoint(mouse_pos): + hover_idx = i + break + active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx + + for i, label in enumerate(MAIN_MENU_ITEMS): + r = rects[i] + enabled = (i != 0) or has_saves + active = enabled and (i == active_idx) + fill = (60, 60, 60) if active else (40, 40, 40) + border = (140, 140, 140) if active else (90, 90, 90) + pygame.draw.rect(surface, fill, r, border_radius=6) + pygame.draw.rect(surface, border, r, 2, border_radius=6) + col = (245, 245, 245) if enabled else (95, 95, 95) + text = font.render(label, True, col) + surface.blit(text, (r.x + 6, r.y + 2)) + + wr = get_wipe_button_rect() + wfill = (70, 40, 40) if has_saves else (40, 40, 40) + wborder = (160, 80, 80) if has_saves else (90, 90, 90) + pygame.draw.rect(surface, wfill, wr, border_radius=6) + pygame.draw.rect(surface, wborder, wr, 2, border_radius=6) + wtext = font.render("Wipe All Saves", True, (245, 170, 170) if has_saves else (95, 95, 95)) + surface.blit(wtext, (wr.x + 8, wr.y + 4)) + + +def get_select_rects(saves) -> list[pygame.Rect]: + rects = [] + start_y = int(HEIGHT * 0.30) + for i in range(len(saves)): + rects.append(pygame.Rect(WIDTH // 2 - 170, start_y + i * 30 - 2, 340, 24)) + return rects + + +def draw_select_menu(surface, selected_idx: int, use_mouse: bool): + title = font.render("Select Game", True, (240, 235, 210)) + surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.15))) + saves = list_saves() + if not saves: + msg = font.render("No save files found.", True, (170, 170, 170)) + surface.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2)) + hint = font.render("Press Esc to return", True, (150, 150, 150)) + surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT // 2 + 30)) + return + rects = get_select_rects(saves) + mouse_pos = pygame.mouse.get_pos() + hover_idx = None + for i, r in enumerate(rects): + if r.collidepoint(mouse_pos): + hover_idx = i + break + active_idx = hover_idx if (use_mouse and hover_idx is not None) else min(selected_idx, len(saves) - 1) + + for i, sv in enumerate(saves): + r = rects[i] + active = (i == active_idx) + fill = (60, 60, 60) if active else (40, 40, 40) + border = (140, 140, 140) if active else (90, 90, 90) + pygame.draw.rect(surface, fill, r, border_radius=6) + pygame.draw.rect(surface, border, r, 2, border_radius=6) + label = f"{sv['name']} ({sv['updated']})" if sv.get("updated") else sv["name"] + text = font.render(label, True, (240, 240, 240)) + surface.blit(text, (r.x + 8, r.y + 3)) + + hint = font.render("Up/Down = select Enter = continue Esc = back", True, (170, 170, 170)) + surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT - 40)) + + +def draw_newgame(surface): + title = font.render("New Game", True, (240, 235, 210)) + surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.22))) + label = font.render("Enter save name:", True, (200, 200, 200)) + surface.blit(label, (WIDTH // 2 - label.get_width() // 2, int(HEIGHT * 0.34))) + box = pygame.Rect(WIDTH // 2 - 140, int(HEIGHT * 0.40), 280, 28) + pygame.draw.rect(surface, (40, 40, 40), box, border_radius=6) + pygame.draw.rect(surface, (140, 140, 140), box, 2, border_radius=6) + display = newgame_name_input + "_" + txt = font.render(display, True, (240, 240, 240)) + surface.blit(txt, (box.x + 8, box.y + 5)) + hint = font.render("Enter = create Esc = cancel", True, (170, 170, 170)) + surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, int(HEIGHT * 0.40) + 40)) + + +def get_confirm_rects() -> list[pygame.Rect]: + yes = pygame.Rect(WIDTH // 2 - 130, HEIGHT // 2 + 30, 110, 30) + no = pygame.Rect(WIDTH // 2 + 20, HEIGHT // 2 + 30, 110, 30) + return [yes, no] + + +def draw_confirm_wipe(surface, selected_idx: int, use_mouse: bool): + overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + surface.blit(overlay, (0, 0)) + n = len(list_saves()) + msg = font.render(f"Delete all {n} save file(s)?", True, (240, 240, 240)) + surface.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2 - 30)) + sub = font.render("This cannot be undone.", True, (200, 90, 90)) + surface.blit(sub, (WIDTH // 2 - sub.get_width() // 2, HEIGHT // 2 - 4)) + + rects = get_confirm_rects() + mouse_pos = pygame.mouse.get_pos() + hover_idx = None + for i, r in enumerate(rects): + if r.collidepoint(mouse_pos): + hover_idx = i + break + active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx + + for i, label in enumerate(["Yes", "No"]): + r = rects[i] + active = (i == active_idx) + if active and label == "Yes": + fill, border = (90, 40, 40), (200, 80, 80) + elif active: + fill, border = (60, 60, 60), (140, 140, 140) + else: + fill, border = (40, 40, 40), (90, 90, 90) + pygame.draw.rect(surface, fill, r, border_radius=6) + pygame.draw.rect(surface, border, r, 2, border_radius=6) + t = font.render(label, True, (245, 245, 245)) + surface.blit(t, (r.x + (r.width - t.get_width()) // 2, r.y + 5)) + + +def handle_settings_keydown(key, return_mode: str): + global settings_wait_action, settings_index, GAME_MODE, pause_menu + if settings_wait_action: + BINDINGS[settings_wait_action] = key + settings_wait_action = None + return True + if key_is("ui_up", key): + settings_index = (settings_index - 1) % len(SETTINGS_ACTIONS) + elif key_is("ui_down", key): + settings_index = (settings_index + 1) % len(SETTINGS_ACTIONS) + elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + settings_wait_action = SETTINGS_ACTIONS[settings_index][1] + elif key_is("ui_cancel", key): + if return_mode == "menu": + GAME_MODE = "menu" + else: + pause_menu = "main" + else: + return False + return True + + +def _activate_menu_item(i: int, has_saves: bool): + global GAME_MODE, running + global select_index, select_use_mouse, newgame_name_input + global settings_index, settings_wait_action + label = MAIN_MENU_ITEMS[i] + if label == "Select Game": + if not has_saves: + return + select_index = 0 + select_use_mouse = False + GAME_MODE = "select" + elif label == "New Game": + newgame_name_input = "" + GAME_MODE = "newgame" + elif label == "Settings": + settings_index = 0 + settings_wait_action = None + GAME_MODE = "settings" + elif label == "Quit": + running = False + + +def _menu_handle_events(): + global running, GAME_MODE, menu_index, menu_use_mouse + global select_index, select_use_mouse, newgame_name_input + global wipe_confirm_idx, wipe_use_mouse, CURRENT_SAVE_NAME, paused + has_saves = len(list_saves()) > 0 + + for e in pygame.event.get(): + if e.type == pygame.QUIT: + running = False + continue + + if GAME_MODE == "settings": + if e.type == pygame.KEYDOWN: + handle_settings_keydown(e.key, "menu") + + elif GAME_MODE == "confirm_wipe": + if e.type == pygame.KEYDOWN: + if key_is("ui_cancel", e.key): + GAME_MODE = "menu" + elif key_is("ui_left", e.key) or key_is("ui_right", e.key): + wipe_confirm_idx = 1 - wipe_confirm_idx + wipe_use_mouse = False + elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + if wipe_confirm_idx == 0: + wipe_all_saves() + GAME_MODE = "menu" + elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + for i, r in enumerate(get_confirm_rects()): + if r.collidepoint(e.pos): + if i == 0: + wipe_all_saves() + GAME_MODE = "menu" + break + + elif GAME_MODE == "select": + if e.type == pygame.KEYDOWN: + if key_is("ui_cancel", e.key): + GAME_MODE = "menu" + elif key_is("ui_up", e.key): + select_index = (select_index - 1) % max(1, len(list_saves())) + select_use_mouse = False + elif key_is("ui_down", e.key): + select_index = (select_index + 1) % max(1, len(list_saves())) + select_use_mouse = False + elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + svs = list_saves() + if svs: + idx = min(select_index, len(svs) - 1) + if load_game(svs[idx]["name"]): + CURRENT_SAVE_NAME = svs[idx]["name"] + GAME_MODE = "game" + paused = False + elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + svs = list_saves() + for i, r in enumerate(get_select_rects(svs)): + if r.collidepoint(e.pos): + if load_game(svs[i]["name"]): + CURRENT_SAVE_NAME = svs[i]["name"] + GAME_MODE = "game" + paused = False + break + + elif GAME_MODE == "newgame": + if e.type == pygame.KEYDOWN: + if key_is("ui_cancel", e.key): + GAME_MODE = "menu" + elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + name = newgame_name_input.strip() or f"Save {len(list_saves()) + 1}" + CURRENT_SAVE_NAME = start_new_game(name) + GAME_MODE = "game" + paused = False + elif e.key == pygame.K_BACKSPACE: + newgame_name_input = newgame_name_input[:-1] + else: + ch = getattr(e, "unicode", "") or "" + if ch and ch.isprintable() and len(newgame_name_input) < 24: + newgame_name_input += ch + + elif GAME_MODE == "menu": + if e.type == pygame.MOUSEMOTION: + hover = None + for i, r in enumerate(get_main_menu_rects()): + if r.collidepoint(e.pos): + hover = i + break + if hover is not None: + menu_use_mouse = True + menu_index = hover + else: + menu_use_mouse = False + elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + if get_wipe_button_rect().collidepoint(e.pos): + if has_saves: + GAME_MODE = "confirm_wipe" + wipe_confirm_idx = 1 + wipe_use_mouse = True + continue + for i, r in enumerate(get_main_menu_rects()): + if r.collidepoint(e.pos): + menu_use_mouse = True + menu_index = i + _activate_menu_item(i, has_saves) + break + elif e.type == pygame.KEYDOWN: + if key_is("ui_up", e.key): + menu_index = (menu_index - 1) % len(MAIN_MENU_ITEMS) + menu_use_mouse = False + elif key_is("ui_down", e.key): + menu_index = (menu_index + 1) % len(MAIN_MENU_ITEMS) + menu_use_mouse = False + elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + _activate_menu_item(menu_index, has_saves) + + +def _menu_draw(surface): + if GAME_MODE == "settings": + draw_settings_menu(surface, settings_index, settings_wait_action) + elif GAME_MODE == "confirm_wipe": + draw_confirm_wipe(surface, wipe_confirm_idx, wipe_use_mouse) + elif GAME_MODE == "select": + draw_select_menu(surface, select_index, select_use_mouse) + elif GAME_MODE == "newgame": + draw_newgame(surface) + else: + draw_main_menu(surface, menu_index, menu_use_mouse, len(list_saves()) > 0) + + # ---------------------------- # Main loop # ---------------------------- @@ -5878,6 +6430,17 @@ settings_wait_action: str | None = None pause_use_mouse = False while running: dt = clock.tick(60) / 1000.0 + + if save_feedback_timer > 0: + save_feedback_timer = max(0.0, save_feedback_timer - dt) + + if GAME_MODE != "game": + _menu_handle_events() + screen.fill(BG) + _menu_draw(screen) + pygame.display.flip() + continue + if not paused: pickup_feed.update(dt) town_title.update(dt) @@ -5907,8 +6470,12 @@ while running: if idx == 0: paused = False elif idx == 1: - pause_menu = "settings" + _do_save_game() elif idx == 2: + pause_menu = "settings" + elif idx == 3: + _return_to_main_menu() + elif idx == 4: running = False break @@ -5918,33 +6485,26 @@ while running: was_diary_visible = diary_ui.visible if paused and pause_menu == "settings": - if settings_wait_action: - BINDINGS[settings_wait_action] = e.key - settings_wait_action = None - else: - if key_is("ui_up", e.key): - settings_index = (settings_index - 1) % len(SETTINGS_ACTIONS) - elif key_is("ui_down", e.key): - settings_index = (settings_index + 1) % len(SETTINGS_ACTIONS) - elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): - settings_wait_action = SETTINGS_ACTIONS[settings_index][1] - elif key_is("ui_cancel", e.key): - pause_menu = "main" + handle_settings_keydown(e.key, "pause") continue if paused and pause_menu == "main": if key_is("ui_up", e.key): - pause_menu_index = (pause_menu_index - 1) % 3 + pause_menu_index = (pause_menu_index - 1) % 5 pause_use_mouse = False elif key_is("ui_down", e.key): - pause_menu_index = (pause_menu_index + 1) % 3 + pause_menu_index = (pause_menu_index + 1) % 5 pause_use_mouse = False elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): if pause_menu_index == 0: paused = False elif pause_menu_index == 1: - pause_menu = "settings" + _do_save_game() elif pause_menu_index == 2: + pause_menu = "settings" + elif pause_menu_index == 3: + _return_to_main_menu() + elif pause_menu_index == 4: running = False elif key_is("pause", e.key): paused = False @@ -6142,9 +6702,12 @@ while running: screen.blit(pause_text, (WIDTH // 2 - pause_text.get_width() // 2, HEIGHT // 2 - 10)) if pause_menu == "main": draw_pause_menu(screen, pause_menu_index, pause_use_mouse) - draw_pause_controls(screen, 20, HEIGHT // 2 + 90) + draw_pause_controls(screen, 20, HEIGHT // 2 + 190) else: draw_settings_menu(screen, settings_index, settings_wait_action) + if save_feedback_timer > 0: + sf = font.render(save_feedback, True, (150, 220, 150)) + screen.blit(sf, (WIDTH // 2 - sf.get_width() // 2, HEIGHT // 2 + 160)) pygame.display.flip() continue