backup
main menu, roadmap, save file system
This commit is contained in:
+3
-1
@@ -1 +1,3 @@
|
||||
.vs
|
||||
.vs
|
||||
pygame test/pygame test/saves/
|
||||
**/__pycache__/
|
||||
@@ -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.
|
||||
+83
-3
@@ -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?
|
||||
- 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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user