main menu, roadmap, save file system
This commit is contained in:
2026-09-28 09:23:55 +02:00
parent b08a2a3715
commit ac40f2176b
4 changed files with 729 additions and 23 deletions
+582 -19
View File
@@ -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