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
+2
View File
@@ -1 +1,3 @@
.vs .vs
pygame test/pygame test/saves/
**/__pycache__/
+61
View File
@@ -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.
+82 -2
View File
@@ -38,8 +38,7 @@ Tether
How to obtain skill upgrades How to obtain skill upgrades
- vendor (smith? trainer?) that can upgrade one spell (selection can be changed but requires paying for it every time) - 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 - 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)
@@ -48,3 +47,84 @@ Ascended armor
- select X number of traits from the sacrifice to merge onto the main piece - 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 - sacrifice gets destroyed in the process, creating an Ascended armor piece
- cost: many coins/rare resource? - 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?
+582 -19
View File
@@ -2,6 +2,7 @@
import json import json
import random import random
from pathlib import Path from pathlib import Path
from datetime import datetime
from copy import deepcopy from copy import deepcopy
import pygame import pygame
import math import math
@@ -4722,7 +4723,7 @@ def _draw_tether_chain_loop(surface, rect: pygame.Rect, light: bool, cx: float,
def get_pause_menu_rects(): def get_pause_menu_rects():
items = ["Resume", "Settings", "Close Game"] items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"]
rects = [] rects = []
start_y = HEIGHT // 2 + 20 start_y = HEIGHT // 2 + 20
for i, label in enumerate(items): 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): 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() rects = get_pause_menu_rects()
mouse_pos = pygame.mouse.get_pos() mouse_pos = pygame.mouse.get_pos()
hover_idx = None hover_idx = None
@@ -5865,6 +5866,557 @@ def draw_stats_overlay(surface):
ty += 18 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 # Main loop
# ---------------------------- # ----------------------------
@@ -5878,6 +6430,17 @@ settings_wait_action: str | None = None
pause_use_mouse = False pause_use_mouse = False
while running: while running:
dt = clock.tick(60) / 1000.0 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: if not paused:
pickup_feed.update(dt) pickup_feed.update(dt)
town_title.update(dt) town_title.update(dt)
@@ -5907,8 +6470,12 @@ while running:
if idx == 0: if idx == 0:
paused = False paused = False
elif idx == 1: elif idx == 1:
pause_menu = "settings" _do_save_game()
elif idx == 2: elif idx == 2:
pause_menu = "settings"
elif idx == 3:
_return_to_main_menu()
elif idx == 4:
running = False running = False
break break
@@ -5918,33 +6485,26 @@ while running:
was_diary_visible = diary_ui.visible was_diary_visible = diary_ui.visible
if paused and pause_menu == "settings": if paused and pause_menu == "settings":
if settings_wait_action: handle_settings_keydown(e.key, "pause")
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"
continue continue
if paused and pause_menu == "main": if paused and pause_menu == "main":
if key_is("ui_up", e.key): 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 pause_use_mouse = False
elif key_is("ui_down", e.key): 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 pause_use_mouse = False
elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
if pause_menu_index == 0: if pause_menu_index == 0:
paused = False paused = False
elif pause_menu_index == 1: elif pause_menu_index == 1:
pause_menu = "settings" _do_save_game()
elif pause_menu_index == 2: elif pause_menu_index == 2:
pause_menu = "settings"
elif pause_menu_index == 3:
_return_to_main_menu()
elif pause_menu_index == 4:
running = False running = False
elif key_is("pause", e.key): elif key_is("pause", e.key):
paused = False paused = False
@@ -6142,9 +6702,12 @@ while running:
screen.blit(pause_text, (WIDTH // 2 - pause_text.get_width() // 2, HEIGHT // 2 - 10)) screen.blit(pause_text, (WIDTH // 2 - pause_text.get_width() // 2, HEIGHT // 2 - 10))
if pause_menu == "main": if pause_menu == "main":
draw_pause_menu(screen, pause_menu_index, pause_use_mouse) 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: else:
draw_settings_menu(screen, settings_index, settings_wait_action) 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() pygame.display.flip()
continue continue