backup
main menu, roadmap, save file system
This commit is contained in:
@@ -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.
|
||||||
+82
-2
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user