diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc1f55e --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +*.so + +# Virtual environments +venv/ +.venv/ +env/ +.env/ + +# IDE +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +Thumbs.db + +# Pygame saves / game data +saves/ +*.sav + +# Logs +*.log diff --git a/assets/maps/ashenvale.txt b/assets/maps/ashenvale.txt new file mode 100644 index 0000000..b293009 --- /dev/null +++ b/assets/maps/ashenvale.txt @@ -0,0 +1,15 @@ +WWWWWWWWWWWWWWWWWWWW +W..................W +W.WW....WW....WW...W +W.W1....WS....W2...W +W.WW....WW....WW...W +W..................W +W.WW....WW....WW...W +W.WI....W3....W4...W +W.WW....WW....WW...W +W..................W +W..................W +W..................W +W..................W +W........P.........W +WWWWWWWWW..WWWWWWWWW diff --git a/assets/maps/ironhaven.txt b/assets/maps/ironhaven.txt new file mode 100644 index 0000000..78685d9 --- /dev/null +++ b/assets/maps/ironhaven.txt @@ -0,0 +1,15 @@ +WWWWWWWWWWWWWWWWWWWW +W..................W +W.WW....WW....WW...W +W.W1....WS....W2...W +W.WW....WW....WW...W +W..................W +W.WW....WW....WW...W +W.WI....WW....WW...W +W.WW....WW....WW...W +W..................W +W..................W +W..................W +W..................W +W........P.........W +WWWWWWWWW..WWWWWWWWW diff --git a/assets/maps/overworld.txt b/assets/maps/overworld.txt new file mode 100644 index 0000000..723374c --- /dev/null +++ b/assets/maps/overworld.txt @@ -0,0 +1,20 @@ +WWWWWWWWWWWWWWWWWWWWWWWWW +W.......................W +W.GGG..........GGG.....W +W.GGG..WWWW....GGG.....W +W.GGG..W..W....GGG.....W +W......W..W............W +W......WWWW............W +W......................TW +W......................TW +W...WWWW...............W +W...W..W...GGG.........W +W...W..W...GGG.........W +W...WWWW...GGG.........W +W......................TW +W......................TW +W..GGG.GGG.............W +W..GGG.GGG.............W +W..GGG.GGG......N......W +WP.....................WW +WWWWWWWWWWWWWWWWWWWWWWWWW diff --git a/assets/maps/willowmere.txt b/assets/maps/willowmere.txt new file mode 100644 index 0000000..78685d9 --- /dev/null +++ b/assets/maps/willowmere.txt @@ -0,0 +1,15 @@ +WWWWWWWWWWWWWWWWWWWW +W..................W +W.WW....WW....WW...W +W.W1....WS....W2...W +W.WW....WW....WW...W +W..................W +W.WW....WW....WW...W +W.WI....WW....WW...W +W.WW....WW....WW...W +W..................W +W..................W +W..................W +W..................W +W........P.........W +WWWWWWWWW..WWWWWWWWW diff --git a/data/dialog.json b/data/dialog.json new file mode 100644 index 0000000..3dcd8d8 --- /dev/null +++ b/data/dialog.json @@ -0,0 +1,74 @@ +{ + "npcs": { + "ashenvale_elder": { + "name": "Elder Thomas", + "dialogs": { + "default": ["Welcome to Ashenvale, young Knight.", "Strange beasts have been attacking our village lately.", "Please be careful if you venture outside."], + "after_wolf_alpha": ["You defeated that terrible beast!", "The village owes you a great debt.", "I fear something darker stirs in the north..."], + "after_lieutenant": ["You've pushed back the Black Knight's forces!", "There are rumors of a great army gathering in the Dark Lands.", "Be careful, brave Knight."] + } + }, + "ashenvale_merchant": { + "name": "Merchant Sara", + "dialogs": { + "default": ["Welcome to my shop! I have supplies for adventurers."] + } + }, + "ashenvale_innkeeper": { + "name": "Innkeeper Tom", + "dialogs": { + "default": ["Welcome to the Ashenvale Inn!", "A good rest will restore your strength.", "Will you rest here for 50 gold?"] + } + }, + "ironhaven_blacksmith": { + "name": "Blacksmith Erik", + "dialogs": { + "default": ["Best weapons in the region, right here!", "If you're heading to the mountains, you'll need good steel."] + } + }, + "ironhaven_innkeeper": { + "name": "Innkeeper Marta", + "dialogs": { + "default": ["Welcome to the Ironhaven Lodge!", "Rest costs 100 gold here."] + } + }, + "willowmere_healer": { + "name": "Traveling Healer Lyra", + "dialogs": { + "default": ["Magic has returned to this world... and with it, great power.", "I've seen strange lights in the Stormcrag Mountains.", "A traveler once wrote about warm air from solid stone up there...", "The mountains breathe. I swear I felt warm air from solid stone."], + "after_subterra": ["So you found it... the city beneath the mountains.", "I always suspected. The world holds more secrets than we know."] + } + }, + "willowmere_merchant": { + "name": "Merchant Dolph", + "dialogs": { + "default": ["I sell magical supplies. Rare goods for the discerning adventurer."] + } + }, + "subterra_elder_marek": { + "name": "Elder Marek", + "dialogs": { + "default": ["Welcome, surface dweller. We rarely receive visitors.", "100 years ago, our ancestors fled underground when the pandemic struck.", "We have lived here ever since, safe from the surface's dangers.", "I must ask that you keep our existence secret for now.", "We are not yet ready to face the surface world."], + "met_already": ["Welcome back, Knight. Have you found what you were looking for?"] + } + }, + "subterra_archivist_lena": { + "name": "Archivist Lena", + "dialogs": { + "default": ["This library holds records of everyone who ever lived in Subterra.", "60 years ago, two families left for the surface.", "One family's surname... it matches yours, doesn't it?", "Their old home is in the residential quarter to the east.", "Perhaps you'll find answers there."] + } + }, + "subterra_merchant_dax": { + "name": "Merchant Dax", + "dialogs": { + "default": ["Welcome! We don't get many customers from the surface.", "I have items you won't find anywhere up there."] + } + }, + "tutorial_npc": { + "name": "Village Guard", + "dialogs": { + "default": ["Press arrow keys or WASD to move.", "Press Z or ENTER to interact with NPCs and objects.", "Press ESC to open your menu.", "Good luck on your journey, Knight!"] + } + } + } +} diff --git a/data/encounters.json b/data/encounters.json new file mode 100644 index 0000000..649b2eb --- /dev/null +++ b/data/encounters.json @@ -0,0 +1,50 @@ +{ + "grasslands": [ + ["dire_wolf"], + ["giant_rat", "giant_rat"], + ["feral_boar"], + ["venomous_viper", "giant_rat"], + ["dire_wolf", "giant_rat"], + ["venomous_viper", "venomous_viper"], + ["feral_boar", "giant_rat"], + ["dire_wolf", "dire_wolf"] + ], + "forest": [ + ["shadow_panther"], + ["thunder_hawk"], + ["iron_bear"], + ["poison_toad", "poison_toad"], + ["shadow_panther", "poison_toad"], + ["thunder_hawk", "poison_toad"], + ["shadow_panther", "shadow_panther"], + ["iron_bear", "poison_toad"] + ], + "mountains": [ + ["rock_golem"], + ["storm_drake"], + ["crystal_stag"], + ["fire_lizard", "fire_lizard"], + ["rock_golem", "fire_lizard"], + ["storm_drake", "crystal_stag"], + ["crystal_stag", "crystal_stag"], + ["fire_lizard", "rock_golem"] + ], + "dark_lands": [ + ["black_knight_soldier"], + ["corrupted_wolf"], + ["nightmare_stallion"], + ["dark_mage", "black_knight_soldier"], + ["corrupted_wolf", "corrupted_wolf"], + ["black_knight_soldier", "black_knight_soldier"], + ["dark_mage", "dark_mage"], + ["nightmare_stallion", "dark_mage"] + ], + "subterra_passage": [ + ["cave_crawler"], + ["pale_lurker", "pale_lurker"], + ["cave_crawler", "pale_lurker"], + ["cave_crawler", "cave_crawler"], + ["pale_lurker"], + ["cave_crawler", "pale_lurker", "pale_lurker"] + ] +} diff --git a/data/enemies.json b/data/enemies.json new file mode 100644 index 0000000..7f1bcd7 --- /dev/null +++ b/data/enemies.json @@ -0,0 +1,487 @@ +{ + "enemies": [ + { + "id": "dire_wolf", + "name": "Dire Wolf", + "hp": 45, + "max_hp": 45, + "str": 12, + "def": 5, + "mag": 4, + "mdf": 4, + "spd": 8, + "lck": 5, + "xp": 30, + "gold": 20, + "weaknesses": ["fire"], + "resistances": [], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [100, 80, 60], + "zone": "grasslands" + }, + { + "id": "giant_rat", + "name": "Giant Rat", + "hp": 25, + "max_hp": 25, + "str": 8, + "def": 3, + "mag": 2, + "mdf": 2, + "spd": 10, + "lck": 3, + "xp": 15, + "gold": 10, + "weaknesses": [], + "resistances": [], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [120, 100, 80], + "zone": "grasslands" + }, + { + "id": "feral_boar", + "name": "Feral Boar", + "hp": 60, + "max_hp": 60, + "str": 14, + "def": 10, + "mag": 2, + "mdf": 4, + "spd": 5, + "lck": 4, + "xp": 40, + "gold": 25, + "weaknesses": [], + "resistances": [], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [160, 90, 40], + "zone": "grasslands" + }, + { + "id": "venomous_viper", + "name": "Venomous Viper", + "hp": 30, + "max_hp": 30, + "str": 10, + "def": 4, + "mag": 6, + "mdf": 5, + "spd": 12, + "lck": 6, + "xp": 35, + "gold": 15, + "weaknesses": [], + "resistances": [], + "immunities": [], + "status_chance": {"poison": 0.3}, + "ai_type": "status", + "sprite_color": [60, 160, 40], + "zone": "grasslands" + }, + { + "id": "shadow_panther", + "name": "Shadow Panther", + "hp": 120, + "max_hp": 120, + "str": 22, + "def": 8, + "mag": 10, + "mdf": 8, + "spd": 18, + "lck": 10, + "xp": 80, + "gold": 50, + "weaknesses": ["light"], + "resistances": ["dark"], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [30, 20, 50], + "zone": "forest" + }, + { + "id": "thunder_hawk", + "name": "Thunder Hawk", + "hp": 90, + "max_hp": 90, + "str": 20, + "def": 6, + "mag": 18, + "mdf": 10, + "spd": 16, + "lck": 8, + "xp": 70, + "gold": 45, + "weaknesses": ["earth"], + "resistances": ["lightning"], + "immunities": [], + "status_chance": {}, + "ai_type": "magic", + "sprite_color": [220, 200, 50], + "zone": "forest" + }, + { + "id": "iron_bear", + "name": "Iron Bear", + "hp": 200, + "max_hp": 200, + "str": 28, + "def": 20, + "mag": 5, + "mdf": 10, + "spd": 4, + "lck": 3, + "xp": 120, + "gold": 70, + "weaknesses": ["fire"], + "resistances": ["physical"], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [80, 80, 90], + "zone": "forest" + }, + { + "id": "poison_toad", + "name": "Poison Toad", + "hp": 70, + "max_hp": 70, + "str": 15, + "def": 6, + "mag": 14, + "mdf": 8, + "spd": 9, + "lck": 6, + "xp": 65, + "gold": 40, + "weaknesses": ["fire"], + "resistances": ["water"], + "immunities": [], + "status_chance": {"poison": 0.4, "blind": 0.25}, + "ai_type": "status", + "sprite_color": [80, 160, 80], + "zone": "forest" + }, + { + "id": "rock_golem", + "name": "Rock Golem", + "hp": 350, + "max_hp": 350, + "str": 35, + "def": 30, + "mag": 5, + "mdf": 15, + "spd": 3, + "lck": 2, + "xp": 200, + "gold": 120, + "weaknesses": ["lightning"], + "resistances": [], + "immunities": ["physical"], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [120, 110, 90], + "zone": "mountains" + }, + { + "id": "storm_drake", + "name": "Storm Drake", + "hp": 280, + "max_hp": 280, + "str": 32, + "def": 12, + "mag": 28, + "mdf": 16, + "spd": 14, + "lck": 10, + "xp": 220, + "gold": 140, + "weaknesses": ["earth"], + "resistances": ["lightning", "wind"], + "immunities": [], + "status_chance": {}, + "ai_type": "magic", + "sprite_color": [80, 120, 200], + "zone": "mountains" + }, + { + "id": "crystal_stag", + "name": "Crystal Stag", + "hp": 180, + "max_hp": 180, + "str": 25, + "def": 15, + "mag": 22, + "mdf": 20, + "spd": 12, + "lck": 18, + "xp": 180, + "gold": 110, + "weaknesses": ["dark"], + "resistances": ["light"], + "immunities": [], + "status_chance": {}, + "ai_type": "healer", + "sprite_color": [180, 220, 240], + "zone": "mountains" + }, + { + "id": "fire_lizard", + "name": "Fire Lizard", + "hp": 220, + "max_hp": 220, + "str": 28, + "def": 10, + "mag": 24, + "mdf": 12, + "spd": 11, + "lck": 7, + "xp": 190, + "gold": 115, + "weaknesses": ["ice"], + "resistances": ["fire"], + "immunities": [], + "status_chance": {"poison": 0.15}, + "ai_type": "magic", + "sprite_color": [220, 80, 30], + "zone": "mountains" + }, + { + "id": "black_knight_soldier", + "name": "Black Knight Soldier", + "hp": 300, + "max_hp": 300, + "str": 40, + "def": 22, + "mag": 12, + "mdf": 14, + "spd": 12, + "lck": 8, + "xp": 280, + "gold": 180, + "weaknesses": ["light"], + "resistances": ["dark"], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [30, 30, 40], + "zone": "dark_lands" + }, + { + "id": "corrupted_wolf", + "name": "Corrupted Wolf", + "hp": 250, + "max_hp": 250, + "str": 38, + "def": 14, + "mag": 20, + "mdf": 16, + "spd": 16, + "lck": 10, + "xp": 260, + "gold": 160, + "weaknesses": ["light"], + "resistances": ["dark"], + "immunities": [], + "status_chance": {"poison": 0.2}, + "ai_type": "aggressive", + "sprite_color": [60, 20, 80], + "zone": "dark_lands" + }, + { + "id": "nightmare_stallion", + "name": "Nightmare Stallion", + "hp": 400, + "max_hp": 400, + "str": 42, + "def": 18, + "mag": 22, + "mdf": 18, + "spd": 14, + "lck": 10, + "xp": 320, + "gold": 200, + "weaknesses": ["light"], + "resistances": ["dark", "fire"], + "immunities": [], + "status_chance": {}, + "ai_type": "haste_self", + "sprite_color": [20, 10, 30], + "zone": "dark_lands" + }, + { + "id": "dark_mage", + "name": "Dark Mage", + "hp": 200, + "max_hp": 200, + "str": 20, + "def": 10, + "mag": 40, + "mdf": 20, + "spd": 13, + "lck": 12, + "xp": 300, + "gold": 190, + "weaknesses": ["light"], + "resistances": ["dark"], + "immunities": [], + "status_chance": {"silence": 0.3, "blind": 0.2}, + "ai_type": "magic", + "sprite_color": [80, 20, 120], + "zone": "dark_lands" + }, + { + "id": "cave_crawler", + "name": "Cave Crawler", + "hp": 200, + "max_hp": 200, + "str": 25, + "def": 18, + "mag": 8, + "mdf": 10, + "spd": 7, + "lck": 4, + "xp": 150, + "gold": 90, + "weaknesses": ["fire"], + "resistances": [], + "immunities": [], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [100, 80, 60], + "zone": "subterra_passage" + }, + { + "id": "pale_lurker", + "name": "Pale Lurker", + "hp": 160, + "max_hp": 160, + "str": 22, + "def": 10, + "mag": 16, + "mdf": 12, + "spd": 20, + "lck": 10, + "xp": 130, + "gold": 80, + "weaknesses": ["light"], + "resistances": [], + "immunities": [], + "status_chance": {"blind": 0.35}, + "ai_type": "status", + "sprite_color": [200, 200, 220], + "zone": "subterra_passage" + }, + { + "id": "crystal_sentinel", + "name": "Crystal Sentinel", + "hp": 400, + "max_hp": 400, + "str": 38, + "def": 25, + "mag": 15, + "mdf": 30, + "spd": 8, + "lck": 6, + "xp": 400, + "gold": 250, + "weaknesses": ["physical"], + "resistances": [], + "immunities": ["magic"], + "status_chance": {}, + "ai_type": "aggressive", + "sprite_color": [160, 220, 255], + "zone": "subterra_passage" + }, + { + "id": "dire_wolf_alpha", + "name": "Dire Wolf Alpha", + "hp": 320, + "max_hp": 320, + "str": 22, + "def": 12, + "mag": 8, + "mdf": 8, + "spd": 12, + "lck": 8, + "xp": 300, + "gold": 150, + "weaknesses": ["fire"], + "resistances": [], + "immunities": [], + "status_chance": {}, + "ai_type": "boss_wolf", + "sprite_color": [140, 110, 80], + "zone": "boss" + }, + { + "id": "black_knight_lieutenant", + "name": "Black Knight Lieutenant", + "hp": 800, + "max_hp": 800, + "str": 45, + "def": 25, + "mag": 30, + "mdf": 20, + "spd": 14, + "lck": 10, + "xp": 800, + "gold": 500, + "weaknesses": ["light"], + "resistances": ["dark"], + "immunities": [], + "status_chance": {}, + "ai_type": "boss_knight", + "sprite_color": [20, 20, 40], + "zone": "boss" + }, + { + "id": "corrupted_beast_king", + "name": "Corrupted Beast King", + "hp": 1500, + "max_hp": 1500, + "str": 55, + "def": 30, + "mag": 40, + "mdf": 25, + "spd": 16, + "lck": 12, + "xp": 1500, + "gold": 1000, + "weaknesses": ["light"], + "resistances": ["dark", "fire"], + "immunities": [], + "status_chance": {}, + "ai_type": "boss_phase", + "sprite_color": [100, 30, 30], + "zone": "boss" + }, + { + "id": "the_black_knight", + "name": "The Black Knight", + "hp": 2500, + "max_hp": 2500, + "str": 65, + "def": 35, + "mag": 50, + "mdf": 30, + "spd": 18, + "lck": 15, + "xp": 3000, + "gold": 2000, + "weaknesses": ["light"], + "resistances": ["dark", "fire", "ice"], + "immunities": [], + "status_chance": {}, + "ai_type": "boss_final", + "sprite_color": [10, 10, 20], + "zone": "boss" + } + ] +} diff --git a/data/items.json b/data/items.json new file mode 100644 index 0000000..89692ea --- /dev/null +++ b/data/items.json @@ -0,0 +1,450 @@ +{ + "items": [ + { + "id": "potion", + "name": "Potion", + "type": "consumable", + "slot": null, + "effect": "heal_hp", + "power": 100, + "price": 50, + "usable_in_battle": true, + "description": "Restores 100 HP.", + "stats": {} + }, + { + "id": "hi_potion", + "name": "Hi-Potion", + "type": "consumable", + "slot": null, + "effect": "heal_hp", + "power": 500, + "price": 300, + "usable_in_battle": true, + "description": "Restores 500 HP.", + "stats": {} + }, + { + "id": "ether", + "name": "Ether", + "type": "consumable", + "slot": null, + "effect": "heal_mp", + "power": 30, + "price": 200, + "usable_in_battle": true, + "description": "Restores 30 MP.", + "stats": {} + }, + { + "id": "hi_ether", + "name": "Hi-Ether", + "type": "consumable", + "slot": null, + "effect": "heal_mp", + "power": 100, + "price": 800, + "usable_in_battle": true, + "description": "Restores 100 MP.", + "stats": {} + }, + { + "id": "phoenix_down", + "name": "Phoenix Down", + "type": "consumable", + "slot": null, + "effect": "revive", + "power": 25, + "price": 500, + "usable_in_battle": true, + "description": "Revives a fallen ally at 25% HP.", + "stats": {} + }, + { + "id": "elixir", + "name": "Elixir", + "type": "consumable", + "slot": null, + "effect": "heal_full", + "power": null, + "price": 9999, + "usable_in_battle": true, + "description": "Fully restores HP and MP.", + "stats": {} + }, + { + "id": "antidote", + "name": "Antidote", + "type": "consumable", + "slot": null, + "effect": "cure_poison", + "power": null, + "price": 30, + "usable_in_battle": true, + "description": "Cures poison.", + "stats": {} + }, + { + "id": "eye_drops", + "name": "Eye Drops", + "type": "consumable", + "slot": null, + "effect": "cure_blind", + "power": null, + "price": 30, + "usable_in_battle": true, + "description": "Cures blind.", + "stats": {} + }, + { + "id": "echo_herb", + "name": "Echo Herb", + "type": "consumable", + "slot": null, + "effect": "cure_silence", + "power": null, + "price": 50, + "usable_in_battle": true, + "description": "Cures silence.", + "stats": {} + }, + { + "id": "tent", + "name": "Tent", + "type": "consumable", + "slot": null, + "effect": "heal_hp_mp_half", + "power": null, + "price": 400, + "usable_in_battle": false, + "description": "Restores 50% HP and MP. Use at save points.", + "stats": {} + }, + { + "id": "bomb_fragment", + "name": "Bomb Fragment", + "type": "battle_item", + "slot": null, + "effect": "damage", + "power": 200, + "price": 200, + "usable_in_battle": true, + "element": "fire", + "target": "single_enemy", + "description": "Deals fire damage ~200.", + "stats": {} + }, + { + "id": "arctic_wind", + "name": "Arctic Wind", + "type": "battle_item", + "slot": null, + "effect": "damage", + "power": 200, + "price": 200, + "usable_in_battle": true, + "element": "ice", + "target": "single_enemy", + "description": "Deals ice damage ~200.", + "stats": {} + }, + { + "id": "zeus_wrath", + "name": "Zeus' Wrath", + "type": "battle_item", + "slot": null, + "effect": "damage", + "power": 200, + "price": 200, + "usable_in_battle": true, + "element": "lightning", + "target": "single_enemy", + "description": "Deals lightning damage ~200.", + "stats": {} + }, + { + "id": "smoke_bomb", + "name": "Smoke Bomb", + "type": "battle_item", + "slot": null, + "effect": "flee", + "power": null, + "price": 100, + "usable_in_battle": true, + "element": null, + "target": null, + "description": "Guarantees escape from battle.", + "stats": {} + }, + { + "id": "short_sword", + "name": "Short Sword", + "type": "equipment", + "slot": "weapon", + "effect": null, + "power": null, + "price": 200, + "usable_in_battle": false, + "description": "A basic iron sword.", + "stats": {"atk": 15} + }, + { + "id": "longsword", + "name": "Longsword", + "type": "equipment", + "slot": "weapon", + "effect": null, + "power": null, + "price": 800, + "usable_in_battle": false, + "description": "A sturdy longsword.", + "stats": {"atk": 28} + }, + { + "id": "silver_blade", + "name": "Silver Blade", + "type": "equipment", + "slot": "weapon", + "effect": null, + "power": null, + "price": 2000, + "usable_in_battle": false, + "description": "A gleaming silver blade.", + "stats": {"atk": 40} + }, + { + "id": "crystal_blade", + "name": "Crystal Blade", + "type": "equipment", + "slot": "weapon", + "effect": null, + "power": null, + "price": 5000, + "usable_in_battle": false, + "description": "A razor-sharp crystal blade.", + "stats": {"atk": 45} + }, + { + "id": "exo_weapon", + "name": "Exo Weapon", + "type": "equipment", + "slot": "weapon", + "effect": null, + "power": null, + "price": 0, + "usable_in_battle": false, + "unique": true, + "description": "Ancient exosuit sword. Deals hybrid physical+magical damage.", + "stats": {"atk": 80} + }, + { + "id": "buckler", + "name": "Buckler", + "type": "equipment", + "slot": "shield", + "effect": null, + "power": null, + "price": 150, + "usable_in_battle": false, + "description": "A light wooden shield.", + "stats": {"def": 5} + }, + { + "id": "iron_shield", + "name": "Iron Shield", + "type": "equipment", + "slot": "shield", + "effect": null, + "power": null, + "price": 600, + "usable_in_battle": false, + "description": "A sturdy iron shield.", + "stats": {"def": 12} + }, + { + "id": "silver_shield", + "name": "Silver Shield", + "type": "equipment", + "slot": "shield", + "effect": null, + "power": null, + "price": 2500, + "usable_in_battle": false, + "description": "A polished silver shield.", + "stats": {"def": 20} + }, + { + "id": "leather_cap", + "name": "Leather Cap", + "type": "equipment", + "slot": "helmet", + "effect": null, + "power": null, + "price": 100, + "usable_in_battle": false, + "description": "A simple leather cap.", + "stats": {"def": 3} + }, + { + "id": "iron_helm", + "name": "Iron Helm", + "type": "equipment", + "slot": "helmet", + "effect": null, + "power": null, + "price": 500, + "usable_in_battle": false, + "description": "A heavy iron helm.", + "stats": {"def": 8} + }, + { + "id": "silver_helm", + "name": "Silver Helm", + "type": "equipment", + "slot": "helmet", + "effect": null, + "power": null, + "price": 2000, + "usable_in_battle": false, + "description": "A gleaming silver helm.", + "stats": {"def": 15} + }, + { + "id": "leather_armor", + "name": "Leather Armor", + "type": "equipment", + "slot": "armor", + "effect": null, + "power": null, + "price": 200, + "usable_in_battle": false, + "description": "Basic leather armor.", + "stats": {"def": 8} + }, + { + "id": "chain_mail", + "name": "Chain Mail", + "type": "equipment", + "slot": "armor", + "effect": null, + "power": null, + "price": 1000, + "usable_in_battle": false, + "description": "Interlocked metal rings.", + "stats": {"def": 18} + }, + { + "id": "silver_armor", + "name": "Silver Armor", + "type": "equipment", + "slot": "armor", + "effect": null, + "power": null, + "price": 4000, + "usable_in_battle": false, + "description": "Gleaming silver armor.", + "stats": {"def": 30} + }, + { + "id": "exo_armor", + "name": "Exo Armor", + "type": "equipment", + "slot": "armor", + "effect": null, + "power": null, + "price": 0, + "usable_in_battle": false, + "unique": true, + "description": "Ancient exosuit armor. Reduces elemental damage by 25%.", + "stats": {"def": 60, "mdf": 40} + }, + { + "id": "silver_ring", + "name": "Silver Ring", + "type": "equipment", + "slot": "accessory", + "effect": null, + "power": null, + "price": 500, + "usable_in_battle": false, + "description": "Improves luck.", + "stats": {"lck": 10} + }, + { + "id": "speed_boots", + "name": "Speed Boots", + "type": "equipment", + "slot": "accessory", + "effect": null, + "power": null, + "price": 800, + "usable_in_battle": false, + "description": "Increases movement speed.", + "stats": {"spd": 8} + }, + { + "id": "power_ring", + "name": "Power Ring", + "type": "equipment", + "slot": "accessory", + "effect": null, + "power": null, + "price": 1000, + "usable_in_battle": false, + "description": "Boosts strength.", + "stats": {"str": 5} + }, + { + "id": "magic_pendant", + "name": "Magic Pendant", + "type": "equipment", + "slot": "accessory", + "effect": null, + "power": null, + "price": 1200, + "usable_in_battle": false, + "description": "Enhances magic power and defense.", + "stats": {"mag": 5, "mdf": 5} + }, + { + "id": "nano_potion", + "name": "Nano Potion", + "type": "consumable", + "slot": null, + "effect": "heal_hp", + "power": 999, + "price": 2000, + "usable_in_battle": true, + "description": "Restores 999 HP.", + "stats": {} + }, + { + "id": "plasma_grenade", + "name": "Plasma Grenade", + "type": "battle_item", + "slot": null, + "effect": "damage", + "power": 800, + "price": 1500, + "usable_in_battle": true, + "element": null, + "target": "all_enemies", + "description": "Deals heavy non-elemental damage to all enemies.", + "stats": {} + }, + { + "id": "stasis_field", + "name": "Stasis Field", + "type": "battle_item", + "slot": null, + "effect": "sleep_all", + "power": null, + "price": 1000, + "usable_in_battle": true, + "element": null, + "target": "all_enemies", + "description": "Inflicts sleep on all enemies (80% chance).", + "stats": {} + } + ] +} diff --git a/data/levels.json b/data/levels.json new file mode 100644 index 0000000..2707c84 --- /dev/null +++ b/data/levels.json @@ -0,0 +1,364 @@ +{ + "levels": [ + { + "level": 1, + "xp_required": 0, + "hp_gain": 0, + "mp_gain": 0, + "str_gain": 0, + "def_gain": 0, + "mag_gain": 0, + "mdf_gain": 0, + "spd_gain": 0, + "lck_gain": 0 + }, + { + "level": 2, + "xp_required": 100, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 3, + "xp_required": 150, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 4, + "xp_required": 225, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 5, + "xp_required": 338, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 6, + "xp_required": 507, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 7, + "xp_required": 760, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 8, + "xp_required": 1140, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 9, + "xp_required": 1710, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 10, + "xp_required": 2565, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 11, + "xp_required": 3848, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 12, + "xp_required": 5772, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 13, + "xp_required": 8658, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 14, + "xp_required": 12987, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 15, + "xp_required": 19480, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 16, + "xp_required": 29220, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 17, + "xp_required": 43830, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 18, + "xp_required": 65745, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 19, + "xp_required": 98618, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 20, + "xp_required": 147927, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 21, + "xp_required": 221890, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 22, + "xp_required": 332835, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 23, + "xp_required": 499252, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 24, + "xp_required": 748878, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 25, + "xp_required": 1123317, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 26, + "xp_required": 1684976, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 27, + "xp_required": 2527464, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 28, + "xp_required": 3791196, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + }, + { + "level": 29, + "xp_required": 5686794, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 0 + }, + { + "level": 30, + "xp_required": 8530191, + "hp_gain": 5, + "mp_gain": 2, + "str_gain": 1, + "def_gain": 1, + "mag_gain": 1, + "mdf_gain": 1, + "spd_gain": 1, + "lck_gain": 1 + } + ] +} diff --git a/data/shops.json b/data/shops.json new file mode 100644 index 0000000..3e25620 --- /dev/null +++ b/data/shops.json @@ -0,0 +1,22 @@ +{ + "ashenvale_shop": { + "name": "Ashenvale General Store", + "sell_ratio": 0.5, + "items": ["potion", "antidote", "eye_drops", "echo_herb", "tent", "short_sword", "leather_armor", "buckler", "leather_cap"] + }, + "ironhaven_shop": { + "name": "Ironhaven Armory", + "sell_ratio": 0.5, + "items": ["potion", "hi_potion", "ether", "antidote", "tent", "longsword", "chain_mail", "iron_shield", "iron_helm", "speed_boots", "silver_ring"] + }, + "willowmere_shop": { + "name": "Willowmere Magic Shop", + "sell_ratio": 0.5, + "items": ["hi_potion", "ether", "hi_ether", "phoenix_down", "echo_herb", "bomb_fragment", "arctic_wind", "zeus_wrath", "smoke_bomb", "magic_pendant"] + }, + "subterra_shop": { + "name": "Subterra Market", + "sell_ratio": 0.5, + "items": ["nano_potion", "plasma_grenade", "stasis_field", "elixir", "silver_blade", "silver_armor", "silver_shield", "silver_helm"] + } +} diff --git a/data/spells.json b/data/spells.json new file mode 100644 index 0000000..0da1c3e --- /dev/null +++ b/data/spells.json @@ -0,0 +1,204 @@ +{ + "spells": [ + { + "id": "cure", + "name": "Cure", + "mp_cost": 4, + "type": "heal", + "power": 100, + "element": null, + "target": "single_ally", + "effect": null, + "learn_level": 1, + "description": "Restores a small amount of HP." + }, + { + "id": "protect", + "name": "Protect", + "mp_cost": 6, + "type": "buff", + "power": null, + "element": null, + "target": "single_ally", + "effect": "protect", + "buff": "protect", + "duration": 5, + "learn_level": 5, + "description": "Raises physical defense." + }, + { + "id": "cura", + "name": "Cura", + "mp_cost": 12, + "type": "heal", + "power": 400, + "element": null, + "target": "single_ally", + "effect": null, + "learn_level": 12, + "description": "Restores a large amount of HP." + }, + { + "id": "shell", + "name": "Shell", + "mp_cost": 8, + "type": "buff", + "power": null, + "element": null, + "target": "single_ally", + "effect": "shell", + "buff": "shell", + "duration": 5, + "learn_level": 15, + "description": "Raises magic defense." + }, + { + "id": "raise_spell", + "name": "Raise", + "mp_cost": 20, + "type": "revive", + "power": 50, + "element": null, + "target": "single_ally", + "effect": null, + "learn_level": 20, + "description": "Revives a fallen ally at 50% HP." + }, + { + "id": "holy", + "name": "Holy", + "mp_cost": 30, + "type": "magic_damage", + "power": 200, + "element": "holy", + "target": "single_enemy", + "effect": null, + "learn_level": 30, + "description": "Deals holy magic damage." + }, + { + "id": "fire", + "name": "Fire", + "mp_cost": 5, + "type": "magic_damage", + "power": 80, + "element": "fire", + "target": "single_enemy", + "effect": null, + "learn_level": 3, + "description": "Deals fire magic damage." + }, + { + "id": "blizzard", + "name": "Blizzard", + "mp_cost": 5, + "type": "magic_damage", + "power": 80, + "element": "ice", + "target": "single_enemy", + "effect": null, + "learn_level": 6, + "description": "Deals ice magic damage." + }, + { + "id": "thunder", + "name": "Thunder", + "mp_cost": 5, + "type": "magic_damage", + "power": 80, + "element": "lightning", + "target": "single_enemy", + "effect": null, + "learn_level": 9, + "description": "Deals lightning magic damage." + }, + { + "id": "fira", + "name": "Fira", + "mp_cost": 15, + "type": "magic_damage", + "power": 200, + "element": "fire", + "target": "single_enemy", + "effect": null, + "learn_level": 18, + "description": "Deals strong fire magic damage." + }, + { + "id": "blizzara", + "name": "Blizzara", + "mp_cost": 15, + "type": "magic_damage", + "power": 200, + "element": "ice", + "target": "single_enemy", + "effect": null, + "learn_level": 22, + "description": "Deals strong ice magic damage." + }, + { + "id": "thundara", + "name": "Thundara", + "mp_cost": 15, + "type": "magic_damage", + "power": 200, + "element": "lightning", + "target": "single_enemy", + "effect": null, + "learn_level": 26, + "description": "Deals strong lightning magic damage." + }, + { + "id": "scan", + "name": "Scan", + "mp_cost": 2, + "type": "scan", + "power": null, + "element": null, + "target": "single_enemy", + "effect": null, + "learn_level": 1, + "description": "Reveals enemy stats and weaknesses." + }, + { + "id": "haste", + "name": "Haste", + "mp_cost": 8, + "type": "buff", + "power": null, + "element": null, + "target": "single_ally", + "effect": "haste", + "buff": "haste", + "duration": 5, + "learn_level": 10, + "description": "Increases speed." + }, + { + "id": "slow", + "name": "Slow", + "mp_cost": 6, + "type": "debuff", + "power": null, + "element": null, + "target": "single_enemy", + "effect": "slow", + "debuff": "slow", + "duration": 3, + "learn_level": 8, + "description": "Decreases enemy speed." + }, + { + "id": "dispel", + "name": "Dispel", + "mp_cost": 10, + "type": "dispel", + "power": null, + "element": null, + "target": "single_ally", + "effect": null, + "learn_level": 16, + "description": "Removes all status effects." + } + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc4caf8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pygame-ce>=2.5.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..719dc7b --- /dev/null +++ b/run.py @@ -0,0 +1,5 @@ +"""Entry point – launch the game.""" +from src.game import Game + +if __name__ == "__main__": + Game().run() diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..210a119 --- /dev/null +++ b/settings.py @@ -0,0 +1,38 @@ +# Core display settings +NATIVE_W = 256 +NATIVE_H = 224 +SCALE = 3 +SCREEN_W = NATIVE_W * SCALE # 768 +SCREEN_H = NATIVE_H * SCALE # 672 + +FPS = 60 +TITLE = "Post-Pandemic Fantasy RPG" +TILE_SIZE = 16 + +# Player movement speed in native pixels per second +PLAYER_SPEED = 90 + +# Colours +BLACK = (0, 0, 0) +WHITE = (255, 255, 255) +RED = (220, 50, 50) +GREEN = (50, 200, 50) +DARK_GREEN = (20, 80, 20) +BLUE = (50, 100, 220) +YELLOW = (255, 230, 50) +GREY = (120, 120, 120) +DARK_GREY = (40, 40, 40) +BROWN = (139, 90, 43) +TEAL = (0, 180, 160) +PURPLE = (140, 60, 200) + +# Tile colours (used when image assets are absent) +TILE_WALL_COLOR = (80, 60, 40) +TILE_FLOOR_COLOR = (140, 120, 80) +TILE_GRASS_COLOR = (60, 140, 60) +TILE_TOWN_COLOR = (180, 160, 80) +TILE_NPC_COLOR = (200, 100, 50) + +# Encounter tuning +ENCOUNTER_STEP_MIN = 80 +ENCOUNTER_STEP_MAX = 120 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/entities/__init__.py b/src/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/entities/enemy.py b/src/entities/enemy.py new file mode 100644 index 0000000..0382f2a --- /dev/null +++ b/src/entities/enemy.py @@ -0,0 +1,167 @@ +"""Enemy entity: loads from enemies.json data dict, handles battle logic.""" +import random +import pygame + + +class Enemy: + """ + Represents a single enemy combatant in battle. + + All stat names mirror Player where possible so BattleEngine can treat + both sides uniformly. + """ + + def __init__(self, data: dict): + self.id = data["id"] + self.name = data["name"] + + self.max_hp = data["max_hp"] + self.hp = data["hp"] + + self.str_ = data["str"] + self.def_ = data["def"] + self.mag = data["mag"] + self.mdf = data["mdf"] + self.spd = data["spd"] + self.lck = data["lck"] + + self.xp = data["xp"] + self.gold = data["gold"] + + self.weaknesses = list(data.get("weaknesses", [])) + self.resistances = list(data.get("resistances", [])) + self.immunities = list(data.get("immunities", [])) + + # status_chance maps status_name → probability per hit + self.status_chance: dict[str, float] = dict(data.get("status_chance", {})) + + self.ai_type = data.get("ai_type", "aggressive") + + # Active status effects: name → turns remaining + self.status_effects: dict[str, int] = {} + + # Whether this enemy has used its heal-once ability (for boss types) + self._healed_once = False + + # Placeholder sprite: a coloured rectangle + color = tuple(data.get("sprite_color", [200, 50, 50])) + self.image = pygame.Surface((32, 32)) + self.image.fill(color) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + @property + def effective_def(self) -> int: + base = self.def_ + if self.status_effects.get("protect", 0) > 0: + base = int(base * 1.5) + return base + + @property + def effective_mdf(self) -> int: + base = self.mdf + if self.status_effects.get("shell", 0) > 0: + base = int(base * 1.5) + return base + + @property + def effective_spd(self) -> int: + spd = self.spd + if self.status_effects.get("haste", 0) > 0: + spd = int(spd * 1.5) + if self.status_effects.get("slow", 0) > 0: + spd = max(1, spd // 2) + return spd + + # ------------------------------------------------------------------ + # Damage / status + # ------------------------------------------------------------------ + + def take_damage(self, amount: int) -> int: + """Apply damage, clamp to [0, hp]. Returns actual damage dealt.""" + actual = min(amount, self.hp) + self.hp = max(0, self.hp - amount) + return actual + + def apply_status(self, status: str, turns: int) -> bool: + """ + Try to apply a status effect. + Returns True if applied (immunities are respected). + """ + if status in self.immunities: + return False + self.status_effects[status] = max( + self.status_effects.get(status, 0), turns + ) + return True + + def tick_statuses(self) -> list[str]: + """ + Decrement all active status timers and apply per-turn effects. + Returns a list of human-readable messages. + """ + messages: list[str] = [] + expired: list[str] = [] + + for status, turns in list(self.status_effects.items()): + if status == "poison" and turns > 0: + dmg = max(1, self.max_hp // 16) + self.hp = max(0, self.hp - dmg) + messages.append(f"{self.name} is hurt by poison! ({dmg})") + if turns <= 1: + expired.append(status) + else: + self.status_effects[status] = turns - 1 + + for s in expired: + del self.status_effects[s] + messages.append(f"{self.name}'s {s} wore off.") + + return messages + + # ------------------------------------------------------------------ + # AI + # ------------------------------------------------------------------ + + def ai_action(self, battle_state) -> dict: + """ + Decide an action based on ai_type. + Returns a dict: {"type": str, "target_idx": int, "spell_id": str|None} + """ + # Silenced enemies can't use skills + silenced = self.status_effects.get("silence", 0) > 0 + + if self.ai_type == "aggressive": + return {"type": "attack", "target_idx": 0, "spell_id": None} + + if self.ai_type == "status": + # 40 % chance to try inflicting a status, else attack + if not silenced and random.random() < 0.4: + return {"type": "skill", "target_idx": 0, "spell_id": "status_attack"} + return {"type": "attack", "target_idx": 0, "spell_id": None} + + if self.ai_type == "magic": + if not silenced and random.random() < 0.6: + return {"type": "skill", "target_idx": 0, "spell_id": "magic_attack"} + return {"type": "attack", "target_idx": 0, "spell_id": None} + + if self.ai_type == "healer": + # Heal self if below 50 % HP + if self.hp < self.max_hp // 2 and not silenced: + return {"type": "skill", "target_idx": -1, "spell_id": "cure_self"} + return {"type": "attack", "target_idx": 0, "spell_id": None} + + if self.ai_type == "haste_self": + # Cast haste on self first turn, then attack + if not self.status_effects.get("haste") and not silenced: + return {"type": "skill", "target_idx": -1, "spell_id": "haste_self"} + return {"type": "attack", "target_idx": 0, "spell_id": None} + + # Boss AI types fall back to attack + return {"type": "attack", "target_idx": 0, "spell_id": None} diff --git a/src/entities/npc.py b/src/entities/npc.py new file mode 100644 index 0000000..b8ba611 --- /dev/null +++ b/src/entities/npc.py @@ -0,0 +1,37 @@ +"""NPC entity for town maps.""" +from __future__ import annotations + +import pygame +from settings import TILE_SIZE, GREEN + + +class NPC(pygame.sprite.Sprite): + """A non-player character placed on a town map tile.""" + + _COLOR = (80, 200, 80) # green-ish + + def __init__(self, npc_id: str, col: int, row: int, tile_size: int = TILE_SIZE): + super().__init__() + self.npc_id = npc_id + self.col = col + self.row = row + self.facing = "down" + + size = tile_size - 4 + self.image = pygame.Surface((size, size)) + self.image.fill(self._COLOR) + pygame.draw.rect(self.image, (40, 120, 40), self.image.get_rect(), 1) + + self.rect = self.image.get_rect( + topleft=(col * tile_size + 2, row * tile_size + 2) + ) + + # ------------------------------------------------------------------ + + def get_interaction_rect(self) -> pygame.Rect: + """Expanded rect used to detect player proximity for interaction.""" + return self.rect.inflate(TILE_SIZE * 2, TILE_SIZE * 2) + + def draw(self, surface: pygame.Surface, camera_offset: pygame.Vector2) -> None: + dest = (self.rect.x - int(camera_offset.x), self.rect.y - int(camera_offset.y)) + surface.blit(self.image, dest) diff --git a/src/entities/player.py b/src/entities/player.py new file mode 100644 index 0000000..b8b959c --- /dev/null +++ b/src/entities/player.py @@ -0,0 +1,339 @@ +"""Player entity: stats, movement, collision, inventory, battle methods.""" +from __future__ import annotations + +import json +import pygame +from settings import TILE_SIZE, PLAYER_SPEED, BLUE + + +class Player: + """ + Represents the player character. + + Movement is done in world-pixel coordinates (floats). + Collision is resolved axis-by-axis (x then y) against wall_tiles. + """ + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + def __init__(self, col: int, row: int): + # World position (top-left of sprite, float precision) + self.x = float(col * TILE_SIZE) + self.y = float(row * TILE_SIZE) + + # Visual size (placeholder rectangle) + self.width = 12 + self.height = 12 + + # Core stats + self.hp = 100 + self.max_hp = 100 + self.mp = 30 + self.max_mp = 30 + self.level = 1 + self.exp = 0 + + # Battle stats + self.str_ = 10 # physical attack + self.def_ = 8 # physical defence + self.mag = 8 # magic attack + self.mdf = 6 # magic defence + self.spd = 10 # speed / agility + self.lck = 5 # luck + + # Base stats (unmodified by equipment; updated on level-up) + self._base_str = self.str_ + self._base_def = self.def_ + self._base_mag = self.mag + self._base_mdf = self.mdf + self._base_spd = self.spd + self._base_lck = self.lck + + # Equipment bonus caches (set by InventorySystem.recalculate_stats) + self._equip_atk = 0 + self._equip_def = 0 + + # Equipment (item_id strings or None) — start with basic gear + self.weapon = "short_sword" + self.shield = None + self.helmet = None + self.armor = "leather_armor" + self.accessory = None + + # Inventory: list of dicts {"item_id": str, "qty": int} + self.inventory: list[dict] = [{"item_id": "potion", "qty": 3}] + + # Known spell IDs — start with cure and scan + self.spells: list[str] = ["cure", "scan"] + + # Apply initial equipment bonuses + self._equip_atk = 15 # short_sword atk + self._equip_def = 8 # leather_armor def + + self.gold = 200 + + # Facing direction for animation selection + self.facing = "down" + + # Step counter for random encounters + self.step_count: float = 0.0 + + # Active status effects: name → turns remaining + self.status_effects: dict[str, int] = {} + + # Display name (used by battle HUD) + self.name = "Hero" + + # Placeholder surface (blue rectangle) + self.image = pygame.Surface((self.width, self.height)) + self.image.fill(BLUE) + + self.rect = pygame.Rect( + int(self.x), int(self.y), self.width, self.height + ) + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + def update(self, dt: float, keys, wall_group: pygame.sprite.Group): + """ + Move the player based on held keys, resolving tile collisions. + Returns the distance moved this frame (for encounter step tracking). + """ + dx = dy = 0.0 + + if keys[pygame.K_LEFT] or keys[pygame.K_a]: + dx -= 1.0 + self.facing = "left" + if keys[pygame.K_RIGHT] or keys[pygame.K_d]: + dx += 1.0 + self.facing = "right" + if keys[pygame.K_UP] or keys[pygame.K_w]: + dy -= 1.0 + self.facing = "up" + if keys[pygame.K_DOWN] or keys[pygame.K_s]: + dy += 1.0 + self.facing = "down" + + # Normalise diagonal movement + if dx != 0 and dy != 0: + dx *= 0.7071 + dy *= 0.7071 + + move_x = dx * PLAYER_SPEED * dt + move_y = dy * PLAYER_SPEED * dt + + # Resolve X axis + self.x += move_x + self.rect.x = int(self.x) + if pygame.sprite.spritecollideany(self, wall_group): + self.x -= move_x + self.rect.x = int(self.x) + move_x = 0.0 + + # Resolve Y axis + self.y += move_y + self.rect.y = int(self.y) + if pygame.sprite.spritecollideany(self, wall_group): + self.y -= move_y + self.rect.y = int(self.y) + move_y = 0.0 + + # Track distance walked (in tile units, approx) + distance = ((move_x ** 2 + move_y ** 2) ** 0.5) / TILE_SIZE + self.step_count += distance + return distance + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + def draw(self, surface: pygame.Surface, camera_offset: pygame.Vector2): + dest_x = self.rect.x - int(camera_offset.x) + dest_y = self.rect.y - int(camera_offset.y) + surface.blit(self.image, (dest_x, dest_y)) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @property + def center(self) -> pygame.Vector2: + return pygame.Vector2(self.x + self.width / 2, self.y + self.height / 2) + + def tile_position(self, tile_size: int = TILE_SIZE) -> tuple[int, int]: + """Return current (col, row) grid position.""" + return (int(self.x) // tile_size, int(self.y) // tile_size) + + # ------------------------------------------------------------------ + # Battle properties + # ------------------------------------------------------------------ + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + @property + def effective_spd(self) -> int: + spd = self.spd + if self.status_effects.get("haste", 0) > 0: + spd = int(spd * 1.5) + if self.status_effects.get("slow", 0) > 0: + spd = max(1, spd // 2) + return spd + + @property + def effective_def(self) -> int: + base = self.def_ + if self.status_effects.get("protect", 0) > 0: + base = int(base * 1.5) + return base + + @property + def effective_mdf(self) -> int: + base = self.mdf + if self.status_effects.get("shell", 0) > 0: + base = int(base * 1.5) + return base + + # ------------------------------------------------------------------ + # Equipment bonuses + # ------------------------------------------------------------------ + + def get_attack_power(self) -> int: + """Base str + weapon ATK bonus from equipped weapon.""" + return self.str_ + self._equip_atk + + def get_armor_defense(self) -> int: + """Base def + total defense bonus from shield, armor, and helmet.""" + return self.def_ + self._equip_def + + # ------------------------------------------------------------------ + # Battle actions + # ------------------------------------------------------------------ + + def take_damage(self, amount: int) -> int: + """Apply damage, clamp to 0. Returns actual damage dealt.""" + actual = min(amount, self.hp) + self.hp = max(0, self.hp - amount) + return actual + + def apply_status(self, status: str, turns: int) -> bool: + """Apply status effect; returns True if applied.""" + self.status_effects[status] = max( + self.status_effects.get(status, 0), turns + ) + return True + + def tick_statuses(self) -> list[str]: + """ + Decrement all active status timers and apply per-turn effects. + Returns list of human-readable messages. + """ + messages: list[str] = [] + expired: list[str] = [] + + for status, turns in list(self.status_effects.items()): + if status == "poison" and turns > 0: + dmg = max(1, self.max_hp // 16) + self.hp = max(0, self.hp - dmg) + messages.append(f"{self.name} is hurt by poison! ({dmg})") + if turns <= 1: + expired.append(status) + else: + self.status_effects[status] = turns - 1 + + for s in expired: + del self.status_effects[s] + messages.append(f"{self.name}'s {s} wore off.") + + return messages + + # ------------------------------------------------------------------ + # Progression + # ------------------------------------------------------------------ + + def learn_spell(self, spell_id: str) -> bool: + """Add spell_id to known spells if not already known. Returns True if newly learned.""" + if spell_id not in self.spells: + self.spells.append(spell_id) + return True + return False + + def gain_experience(self, xp: int, levels_data: list | None = None) -> list[str]: + """ + Add *xp* to the player's total and level up if thresholds are passed. + Loads levels.json automatically if *levels_data* is None. + Returns list of level-up messages. + """ + self.exp += xp + messages: list[str] = [] + + if levels_data is None: + try: + with open("data/levels.json", "r", encoding="utf-8") as fh: + levels_data = json.load(fh)["levels"] + except Exception: + return messages + + while True: + # When player is at level N, the next level's data lives at index N + # (levels_data is 0-indexed so index 0 = level 1, index 1 = level 2, …) + next_lvl_idx = self.level + if next_lvl_idx >= len(levels_data): + break + next_entry = levels_data[next_lvl_idx] + if self.exp >= next_entry["xp_required"]: + self.level_up(next_entry) + messages.append(f"Level up! Now level {self.level}!") + else: + break + + return messages + + def level_up(self, level_entry: dict) -> None: + """Apply stat gains from a levels.json entry dict and auto-learn new spells.""" + self.level = level_entry["level"] + hp_gain = level_entry.get("hp_gain", 0) + mp_gain = level_entry.get("mp_gain", 0) + str_gain = level_entry.get("str_gain", 0) + def_gain = level_entry.get("def_gain", 0) + mag_gain = level_entry.get("mag_gain", 0) + mdf_gain = level_entry.get("mdf_gain", 0) + spd_gain = level_entry.get("spd_gain", 0) + lck_gain = level_entry.get("lck_gain", 0) + + self.max_hp += hp_gain + self.max_mp += mp_gain + + # Update base stats + self._base_str = getattr(self, "_base_str", self.str_) + str_gain + self._base_def = getattr(self, "_base_def", self.def_) + def_gain + self._base_mag = getattr(self, "_base_mag", self.mag) + mag_gain + self._base_mdf = getattr(self, "_base_mdf", self.mdf) + mdf_gain + self._base_spd = getattr(self, "_base_spd", self.spd) + spd_gain + self._base_lck = getattr(self, "_base_lck", self.lck) + lck_gain + + # Update effective stats + self.str_ += str_gain + self.def_ += def_gain + self.mag += mag_gain + self.mdf += mdf_gain + self.spd += spd_gain + self.lck += lck_gain + + # Restore HP/MP on level up + self.hp = self.max_hp + self.mp = self.max_mp + + # Auto-learn spells for new level + try: + from src.systems.magic import MagicSystem + ms = MagicSystem() + ms.load_spells("data/spells.json") + for sid in ms.get_learnable_spells(self.level): + self.learn_spell(sid) + except Exception: + pass + + def gain_gold(self, amount: int) -> None: + self.gold += amount diff --git a/src/game.py b/src/game.py new file mode 100644 index 0000000..ea230e3 --- /dev/null +++ b/src/game.py @@ -0,0 +1,123 @@ +""" +Core Game class: initialises pygame, owns the state machine, runs the loop. + +Native resolution: 256×224 (SNES-like). +Displayed at 3× scale → 768×672 window. +""" +import sys +import pygame + +from settings import ( + NATIVE_W, NATIVE_H, SCALE, SCREEN_W, SCREEN_H, FPS, TITLE, BLACK, +) +from src.states.title import TitleState +from src.states.overworld import OverworldState +from src.states.battle import BattleState +from src.states.town import TownState +from src.states.shop import ShopState +from src.states.dialog import DialogState +from src.states.pause_menu import PauseMenuState +from src.states.game_over import GameOverState + + +class Game: + """ + Owns the pygame window, the native surface, the clock, and the + state-machine. All game logic lives in individual State classes. + """ + + def __init__(self): + pygame.init() + pygame.display.set_caption(TITLE) + + # The window shown to the player + self.screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) + + # Low-resolution canvas that every state draws onto; this is then + # scaled up to fill the window once per frame. + self.native = pygame.Surface((NATIVE_W, NATIVE_H)) + + self.clock = pygame.time.Clock() + + # ------------------------------------------------------------------ + # State machine + # ------------------------------------------------------------------ + # All state instances are created once and reused. + self.states: dict = { + "title": TitleState(self), + "overworld": OverworldState(self), + "battle": BattleState(self), + "town": TownState(self), + "shop": ShopState(self), + "dialog": DialogState(self), + "pause_menu": PauseMenuState(self), + "game_over": GameOverState(self), + } + + # History stack – supports push/pop for overlay states (e.g. pause) + self._state_stack: list[str] = [] + + # Boot into the title screen + self.current_state: str = "title" + self.states["title"].enter() + + # ------------------------------------------------------------------ + # State-machine helpers + # ------------------------------------------------------------------ + + def change_state(self, name: str, **kwargs): + """ + Cleanly exit the current state and enter *name*. + Clears the history stack. + """ + self.states[self.current_state].exit() + self._state_stack.clear() + self.current_state = name + self.states[name].enter(**kwargs) + + def push_state(self, name: str, **kwargs): + """ + Push the current state onto the stack and activate *name* on top. + The previous state is suspended (not exited) – useful for overlays + like pause menus. + """ + self._state_stack.append(self.current_state) + self.current_state = name + self.states[name].enter(**kwargs) + + def pop_state(self): + """Return to the most recently pushed state.""" + if not self._state_stack: + return + self.states[self.current_state].exit() + self.current_state = self._state_stack.pop() + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def run(self): + running = True + while running: + dt = self.clock.tick(FPS) / 1000.0 # seconds + + # --- Events ------------------------------------------------- + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + break + self.states[self.current_state].handle_input(event) + + # --- Update ------------------------------------------------- + self.states[self.current_state].update(dt) + + # --- Draw --------------------------------------------------- + self.native.fill(BLACK) + self.states[self.current_state].draw(self.native) + + # Scale native canvas → window + pygame.transform.scale(self.native, (SCREEN_W, SCREEN_H), self.screen) + pygame.display.flip() + + pygame.quit() + sys.exit() diff --git a/src/states/__init__.py b/src/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/states/base_state.py b/src/states/base_state.py new file mode 100644 index 0000000..3aa47df --- /dev/null +++ b/src/states/base_state.py @@ -0,0 +1,27 @@ +"""Abstract base class that every game state inherits from.""" +from abc import ABC, abstractmethod + + +class BaseState(ABC): + def __init__(self, game): + self.game = game + + @abstractmethod + def enter(self, **kwargs): + """Called when the state becomes active.""" + + @abstractmethod + def exit(self): + """Called just before the state is deactivated.""" + + @abstractmethod + def handle_input(self, event): + """Process a single pygame event.""" + + @abstractmethod + def update(self, dt: float): + """Advance game logic. dt is seconds since last frame.""" + + @abstractmethod + def draw(self, surface): + """Render to the native 256×224 surface.""" diff --git a/src/states/battle.py b/src/states/battle.py new file mode 100644 index 0000000..59280a5 --- /dev/null +++ b/src/states/battle.py @@ -0,0 +1,550 @@ +""" +Battle state – full Phase 2 implementation. + +Layout on 256×224 native surface +--------------------------------- +y 0 – 135 : enemy sprites + HP bars (top half) +y 136 – 139 : thin separator +y 140 – 159 : player HUD (HP/MP) + command menu +y 160 – 180 : message log (last 2 lines) +y 181 – 223 : level-up / victory / defeat overlay + +Inner sub-phases +---------------- +"player_input" – command menu shown; waiting for player choice +"player_target" – target selection after Attack is chosen +"player_magic" – spell submenu (future; falls back to attack for now) +"enemy_turn" – engine processes one enemy action per step +"animating" – short pause between actions +"victory" – show rewards; wait for ENTER +"defeat" – brief pause then go to game_over +"fled" – got away; return to overworld +""" +from __future__ import annotations + +import pygame + +from src.states.base_state import BaseState +from src.systems.battle_engine import BattleEngine +from src.systems.battle_engine import BattleState as EngBattleState +from src.systems.magic import MagicSystem +from src.systems.inventory import InventorySystem +from src.ui.battle_hud import BattleHUD +from settings import BLACK, WHITE, RED, YELLOW, GREEN, NATIVE_W, NATIVE_H + + +_COMMANDS = ["Attack", "Magic", "Item", "Defend", "Flee"] + +_BG_TOP = (20, 10, 30) +_BG_BOTTOM = (10, 10, 20) +_SEP_COLOR = (80, 60, 100) + + +class BattleState(BaseState): + """Full battle state with command menu, enemy AI, and rewards.""" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def enter(self, **kwargs): + self._font_lg = pygame.font.SysFont("monospace", 12, bold=True) + self._font_md = pygame.font.SysFont("monospace", 8, bold=True) + self._font_sm = pygame.font.SysFont("monospace", 7) + + self._hud = BattleHUD() + self._engine = BattleEngine() + + # Magic and inventory systems + self._magic = MagicSystem() + self._inv = InventorySystem() + try: + self._magic.load_spells("data/spells.json") + self._inv.load_items("data/items.json") + except Exception: + pass + + # Receive player and enemy list from caller (overworld) + player = kwargs.get("player") + enemies = kwargs.get("enemies", []) + + if player is None or not enemies: + # Safety: if called without proper args just return to overworld + self.game.change_state("overworld") + return + + self._player = player + self._enemies = enemies + + self._bs: EngBattleState = self._engine.start_battle(player, enemies) + + # Inner sub-phase + self._sub_phase = "player_input" + self._cmd_idx = 0 # currently highlighted command + self._target_idx = 0 # highlighted enemy target + + # Enemy-turn queue: list of (enemy, action) to process sequentially + self._enemy_queue: list[tuple] = [] + self._anim_timer = 0.0 # seconds remaining in "animating" pause + + # Magic submenu state + self._magic_cursor = 0 + self._spell_list: list[str] = [] # spell IDs available to cast + self._selected_spell_id: str = "" + + # Item submenu state + self._item_cursor = 0 + self._usable_items: list[dict] = [] # entries from inventory + self._selected_item_id: str = "" + + # Victory rewards + self._total_xp = 0 + self._total_gold = 0 + self._levelup_msgs: list[str] = [] + self._victory_timer = 0.0 + self._defeat_timer = 0.0 + + def exit(self): + pass + + # ------------------------------------------------------------------ + # Input + # ------------------------------------------------------------------ + + def handle_input(self, event): + if event.type != pygame.KEYDOWN: + return + + key = event.key + + if self._sub_phase == "player_input": + self._handle_command_input(key) + + elif self._sub_phase == "player_target": + self._handle_target_input(key) + + elif self._sub_phase == "player_magic": + self._handle_magic_input(key) + + elif self._sub_phase == "player_magic_target": + self._handle_magic_target_input(key) + + elif self._sub_phase == "player_item": + self._handle_item_input(key) + + elif self._sub_phase == "victory": + if key in (pygame.K_RETURN, pygame.K_z): + self.game.change_state("overworld") + + # ------------------------------------------------------------------ + + def _handle_command_input(self, key: int): + if key in (pygame.K_UP, pygame.K_w): + self._cmd_idx = (self._cmd_idx - 1) % len(_COMMANDS) + elif key in (pygame.K_DOWN, pygame.K_s): + self._cmd_idx = (self._cmd_idx + 1) % len(_COMMANDS) + elif key in (pygame.K_RETURN, pygame.K_z): + self._select_command() + + def _select_command(self): + cmd = _COMMANDS[self._cmd_idx] + + if cmd == "Attack": + self._target_idx = 0 + self._sub_phase = "player_target" + + elif cmd == "Magic": + spells = getattr(self._player, "spells", []) + if not spells: + self._bs.log.append("No spells known!") + self._start_enemy_turn() + return + self._spell_list = spells[:] + self._magic_cursor = 0 + self._sub_phase = "player_magic" + + elif cmd == "Item": + usable = self._inv.get_usable_items(self._player.inventory, in_battle=True) + if not usable: + self._bs.log.append("No usable items!") + self._start_enemy_turn() + return + self._usable_items = usable + self._item_cursor = 0 + self._sub_phase = "player_item" + + elif cmd == "Defend": + result = self._engine.execute_action( + self._player, + {"type": "defend", "target_idx": -1, "spell_id": None}, + self._bs, + ) + self._bs.log.append(result.message) + self._start_enemy_turn() + + elif cmd == "Flee": + result = self._engine.execute_action( + self._player, + {"type": "flee", "target_idx": 0, "spell_id": None}, + self._bs, + ) + self._bs.log.append(result.message) + if self._bs.phase == "fled": + self._sub_phase = "fled" + else: + self._start_enemy_turn() + + def _handle_target_input(self, key: int): + alive = [e for e in self._enemies if e.is_alive] + if not alive: + return + if key in (pygame.K_LEFT, pygame.K_a, pygame.K_UP, pygame.K_w): + self._target_idx = (self._target_idx - 1) % len(alive) + elif key in (pygame.K_RIGHT, pygame.K_d, pygame.K_DOWN, pygame.K_s): + self._target_idx = (self._target_idx + 1) % len(alive) + elif key in (pygame.K_RETURN, pygame.K_z): + self._execute_player_action( + {"type": "attack", "target_idx": self._target_idx, "spell_id": None} + ) + elif key in (pygame.K_ESCAPE, pygame.K_x): + self._sub_phase = "player_input" + + def _handle_magic_input(self, key: int): + if not self._spell_list: + self._sub_phase = "player_input" + return + if key in (pygame.K_UP, pygame.K_w): + self._magic_cursor = (self._magic_cursor - 1) % len(self._spell_list) + elif key in (pygame.K_DOWN, pygame.K_s): + self._magic_cursor = (self._magic_cursor + 1) % len(self._spell_list) + elif key in (pygame.K_RETURN, pygame.K_z): + spell_id = self._spell_list[self._magic_cursor] + if spell_id not in self._magic.spells: + self._bs.log.append(f"Spell data not found for {spell_id}.") + return + spell = self._magic.spells[spell_id] + target_type = spell.get("target", "single_enemy") + self._selected_spell_id = spell_id + if target_type == "single_enemy": + self._target_idx = 0 + self._sub_phase = "player_magic_target" + else: + # Self/ally target → cast on player immediately + results = self._magic.cast_spell( + self._player, spell_id, + [self._player], self._bs, self._engine, + ) + for r in results: + self._bs.log.append(r.message) + victory = self._engine.check_victory(self._bs) + if victory == "player_win": + self._begin_victory() + return + self._start_enemy_turn() + elif key in (pygame.K_ESCAPE, pygame.K_x): + self._sub_phase = "player_input" + + def _handle_magic_target_input(self, key: int): + alive = [e for e in self._enemies if e.is_alive] + if not alive: + self._sub_phase = "player_input" + return + if key in (pygame.K_LEFT, pygame.K_a, pygame.K_UP, pygame.K_w): + self._target_idx = (self._target_idx - 1) % len(alive) + elif key in (pygame.K_RIGHT, pygame.K_d, pygame.K_DOWN, pygame.K_s): + self._target_idx = (self._target_idx + 1) % len(alive) + elif key in (pygame.K_RETURN, pygame.K_z): + target = alive[min(self._target_idx, len(alive) - 1)] + results = self._magic.cast_spell( + self._player, self._selected_spell_id, + [target], self._bs, self._engine, + ) + for r in results: + self._bs.log.append(r.message) + victory = self._engine.check_victory(self._bs) + if victory == "player_win": + self._begin_victory() + return + self._start_enemy_turn() + elif key in (pygame.K_ESCAPE, pygame.K_x): + self._sub_phase = "player_magic" + + def _handle_item_input(self, key: int): + if not self._usable_items: + self._sub_phase = "player_input" + return + if key in (pygame.K_UP, pygame.K_w): + self._item_cursor = (self._item_cursor - 1) % len(self._usable_items) + elif key in (pygame.K_DOWN, pygame.K_s): + self._item_cursor = (self._item_cursor + 1) % len(self._usable_items) + elif key in (pygame.K_RETURN, pygame.K_z): + entry = self._usable_items[self._item_cursor] + item_id = entry["item_id"] + item = self._inv.items.get(item_id, {}) + # Determine target + itype = item.get("type", "consumable") + if itype == "battle_item": + i_target_type = item.get("target", "single_enemy") + if i_target_type == "all_enemies": + target = None # use_item handles all_enemies via battle_state + else: + alive = [e for e in self._enemies if e.is_alive] + target = alive[0] if alive else self._player + else: + target = self._player + msg = self._inv.use_item( + item_id, self._player, target, self._player.inventory, self._bs + ) + self._bs.log.append(msg) + # Check if smoke bomb fled + if self._bs.phase == "fled": + self._sub_phase = "fled" + return + victory = self._engine.check_victory(self._bs) + if victory == "player_win": + self._begin_victory() + return + self._start_enemy_turn() + elif key in (pygame.K_ESCAPE, pygame.K_x): + self._sub_phase = "player_input" + + # ------------------------------------------------------------------ + # Action execution + # ------------------------------------------------------------------ + + def _execute_player_action(self, action: dict): + # Tick player status effects before acting + msgs = self._player.tick_statuses() + self._bs.log.extend(msgs) + + result = self._engine.execute_action(self._player, action, self._bs) + self._bs.log.append(result.message) + + victory = self._engine.check_victory(self._bs) + if victory == "player_win": + self._begin_victory() + return + + self._start_enemy_turn() + + def _start_enemy_turn(self): + self._enemy_queue = [ + (e, e.ai_action(self._bs)) + for e in self._enemies + if e.is_alive + ] + self._sub_phase = "enemy_turn" + self._anim_timer = 0.0 + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + + def update(self, dt: float): + if self._sub_phase == "animating": + self._anim_timer -= dt + if self._anim_timer <= 0.0: + self._process_next_enemy() + + elif self._sub_phase == "enemy_turn": + self._process_next_enemy() + + elif self._sub_phase == "defeat": + self._defeat_timer -= dt + if self._defeat_timer <= 0.0: + self.game.change_state("game_over") + + elif self._sub_phase == "fled": + self.game.change_state("overworld") + + def _process_next_enemy(self): + if not self._enemy_queue: + # All enemies acted; back to player input + self._bs.turn_number += 1 + self._sub_phase = "player_input" + return + + enemy, action = self._enemy_queue.pop(0) + + # Tick enemy status effects + msgs = enemy.tick_statuses() + self._bs.log.extend(msgs) + + if not enemy.is_alive: + self._process_next_enemy() + return + + result = self._engine.execute_action(enemy, action, self._bs) + self._bs.log.append(result.message) + + # Check defeat + victory = self._engine.check_victory(self._bs) + if victory == "enemy_win": + self._sub_phase = "defeat" + self._defeat_timer = 2.0 + self._bs.log.append("You were defeated…") + return + + # Pause between enemy actions + self._sub_phase = "animating" + self._anim_timer = 0.5 + + # ------------------------------------------------------------------ + # Victory + # ------------------------------------------------------------------ + + def _begin_victory(self): + self._sub_phase = "victory" + self._total_xp = sum(e.xp for e in self._enemies) + self._total_gold = sum(e.gold for e in self._enemies) + + self._levelup_msgs = self._player.gain_experience(self._total_xp) + self._player.gain_gold(self._total_gold) + + self._bs.log.append(f"Victory! Gained {self._total_xp} XP and {self._total_gold} gold.") + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface): + # Background gradient (two rectangles) + surface.fill(_BG_TOP) + pygame.draw.rect(surface, _BG_BOTTOM, (0, 136, NATIVE_W, NATIVE_H - 136)) + pygame.draw.line(surface, _SEP_COLOR, (0, 136), (NATIVE_W, 136)) + + self._draw_enemies(surface) + self._draw_player_hud(surface) + self._draw_messages(surface) + + if self._sub_phase == "player_input": + self._hud.draw_command_menu(surface, _COMMANDS, self._cmd_idx, 196, 140) + + elif self._sub_phase == "player_target": + self._draw_target_cursor(surface) + self._hud.draw_command_menu(surface, _COMMANDS, self._cmd_idx, 196, 140) + + elif self._sub_phase == "player_magic": + self._draw_magic_menu(surface) + + elif self._sub_phase == "player_magic_target": + self._draw_target_cursor(surface) + self._draw_magic_menu(surface) + + elif self._sub_phase == "player_item": + self._draw_item_menu(surface) + + elif self._sub_phase == "victory": + self._draw_victory_overlay(surface) + + elif self._sub_phase == "defeat": + self._draw_defeat_overlay(surface) + + # ------------------------------------------------------------------ + + def _draw_enemies(self, surface: pygame.Surface): + alive = [e for e in self._enemies if e.is_alive] + count = len(alive) + if count == 0: + return + + spacing = NATIVE_W // (count + 1) + for i, enemy in enumerate(alive): + ex = spacing * (i + 1) - 16 + ey = 30 + surface.blit(enemy.image, (ex, ey)) + # HP bar + name above sprite + self._hud.draw_enemy_info(surface, enemy, ex, ey - 14) + + # Highlight selected target + if self._sub_phase == "player_target" and i == self._target_idx: + pygame.draw.rect(surface, YELLOW, (ex - 2, ey - 2, 36, 36), 2) + + def _draw_player_hud(self, surface: pygame.Surface): + self._hud.draw_player_info(surface, self._player, 6, 140) + + def _draw_messages(self, surface: pygame.Surface): + self._hud.draw_message_log(surface, self._bs.log, 6, 165, width=186) + + def _draw_target_cursor(self, surface: pygame.Surface): + hint = self._font_sm.render("Select target X=back", True, WHITE) + surface.blit(hint, (6, 157)) + + def _draw_magic_menu(self, surface: pygame.Surface): + """Draw spell list overlay.""" + panel_x, panel_y = 4, 137 + panel_w, panel_h = 160, 60 + overlay = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) + overlay.fill((10, 10, 40, 220)) + surface.blit(overlay, (panel_x, panel_y)) + pygame.draw.rect(surface, (180, 180, 200), (panel_x, panel_y, panel_w, panel_h), 1) + + title = self._font_sm.render("Magic - ESC:back", True, YELLOW) + surface.blit(title, (panel_x + 2, panel_y + 2)) + + for i, spell_id in enumerate(self._spell_list[:5]): + iy = panel_y + 12 + i * 9 + spell = self._magic.spells.get(spell_id) + if spell: + label = f"{spell['name']:10s} MP{spell['mp_cost']:2d}" + else: + label = spell_id + if i == self._magic_cursor: + pygame.draw.rect(surface, (60, 60, 120), (panel_x + 1, iy, panel_w - 2, 9)) + color = YELLOW + else: + color = WHITE + s = self._font_sm.render(label, True, color) + surface.blit(s, (panel_x + 4, iy + 1)) + + def _draw_item_menu(self, surface: pygame.Surface): + """Draw usable items overlay.""" + panel_x, panel_y = 4, 137 + panel_w, panel_h = 160, 60 + overlay = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) + overlay.fill((10, 30, 10, 220)) + surface.blit(overlay, (panel_x, panel_y)) + pygame.draw.rect(surface, (180, 200, 180), (panel_x, panel_y, panel_w, panel_h), 1) + + title = self._font_sm.render("Items - ESC:back", True, YELLOW) + surface.blit(title, (panel_x + 2, panel_y + 2)) + + for i, entry in enumerate(self._usable_items[:5]): + iy = panel_y + 12 + i * 9 + item = self._inv.items.get(entry["item_id"], {}) + label = f"{item.get('name', entry['item_id'])[:12]:12s} x{entry['qty']}" + if i == self._item_cursor: + pygame.draw.rect(surface, (20, 80, 20), (panel_x + 1, iy, panel_w - 2, 9)) + color = YELLOW + else: + color = WHITE + s = self._font_sm.render(label, True, color) + surface.blit(s, (panel_x + 4, iy + 1)) + + def _draw_victory_overlay(self, surface: pygame.Surface): + overlay = pygame.Surface((NATIVE_W, NATIVE_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 160)) + surface.blit(overlay, (0, 0)) + + lbl = self._font_lg.render("Victory!", True, YELLOW) + surface.blit(lbl, lbl.get_rect(center=(NATIVE_W // 2, 70))) + + xp_surf = self._font_md.render( + f"+{self._total_xp} XP +{self._total_gold} Gold", True, WHITE + ) + surface.blit(xp_surf, xp_surf.get_rect(center=(NATIVE_W // 2, 95))) + + for i, msg in enumerate(self._levelup_msgs): + s = self._font_sm.render(msg, True, GREEN) + surface.blit(s, s.get_rect(center=(NATIVE_W // 2, 110 + i * 12))) + + hint = self._font_sm.render("Press ENTER to continue", True, WHITE) + surface.blit(hint, hint.get_rect(center=(NATIVE_W // 2, 200))) + + def _draw_defeat_overlay(self, surface: pygame.Surface): + overlay = pygame.Surface((NATIVE_W, NATIVE_H), pygame.SRCALPHA) + overlay.fill((80, 0, 0, 180)) + surface.blit(overlay, (0, 0)) + + lbl = self._font_lg.render("Defeated…", True, RED) + surface.blit(lbl, lbl.get_rect(center=(NATIVE_W // 2, NATIVE_H // 2))) diff --git a/src/states/dialog.py b/src/states/dialog.py new file mode 100644 index 0000000..d0f3565 --- /dev/null +++ b/src/states/dialog.py @@ -0,0 +1,155 @@ +"""Dialog state – Phase 4 full implementation.""" +from __future__ import annotations + +import json +import pygame +from src.states.base_state import BaseState +from src.ui.text_box import TextBox +from settings import WHITE, BLACK, DARK_GREY, YELLOW, NATIVE_W, NATIVE_H + +DIALOG_PATH = "data/dialog.json" + +# Quest flag → dialog branch mapping per NPC +_BRANCH_RULES: dict[str, list[tuple[str, str]]] = { + # npc_id: [(flag_name, branch_name), ...] checked in order + "ashenvale_elder": [ + ("defeated_lieutenant", "after_lieutenant"), + ("defeated_wolf_alpha", "after_wolf_alpha"), + ], + "willowmere_healer": [ + ("found_subterra", "after_subterra"), + ], + "subterra_elder_marek": [ + ("met_elder_marek", "met_already"), + ], +} + + +class DialogState(BaseState): + """Typewriter dialog overlay; pushed on top of town/overworld states.""" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def enter(self, **kwargs): + self._npc_id = kwargs.get("npc_id", "") + self._player = kwargs.get("player") + self._quest_flags: dict = kwargs.get( + "quest_flags", getattr(self.game, "quest_flags", {}) + ) + + # Capture the background frame from the state below us + self._background = self._capture_background() + + # Load dialog data + npc_data = self._load_npc_data() + self._npc_name = npc_data.get("name", "???") + self._lines = self._select_branch(npc_data) + + self._line_idx = 0 + self._text_box: TextBox | None = self._make_box() + + self._font_name = pygame.font.SysFont("monospace", 7, bold=True) + + def exit(self): + pass + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _capture_background(self) -> pygame.Surface: + bg = pygame.Surface((NATIVE_W, NATIVE_H)) + bg.fill(BLACK) + if self.game._state_stack: + prev = self.game.states[self.game._state_stack[-1]] + try: + prev.draw(bg) + except Exception: + pass + return bg + + def _load_npc_data(self) -> dict: + try: + with open(DIALOG_PATH, "r", encoding="utf-8") as fh: + data = json.load(fh) + return data["npcs"].get(self._npc_id, {}) + except Exception as exc: + print(f"[Dialog] Could not load dialog: {exc}") + return {} + + def _select_branch(self, npc_data: dict) -> list[str]: + dialogs = npc_data.get("dialogs", {}) + rules = _BRANCH_RULES.get(self._npc_id, []) + for flag, branch in rules: + if self._quest_flags.get(flag) and branch in dialogs: + return dialogs[branch] + return dialogs.get("default", ["..."]) + + def _make_box(self) -> TextBox | None: + if not self._lines: + return None + return TextBox( + self._lines[self._line_idx], + x=8, y=NATIVE_H - 68, + width=NATIVE_W - 16, + height=60, + font_size=7, + typewriter_speed=40.0, + ) + + # ------------------------------------------------------------------ + # Input + # ------------------------------------------------------------------ + + def handle_input(self, event): + if event.type != pygame.KEYDOWN: + return + key = event.key + + if key == pygame.K_ESCAPE: + self.game.pop_state() + return + + if key in (pygame.K_RETURN, pygame.K_z, pygame.K_SPACE): + if self._text_box and self._text_box.advance(): + # Box complete – advance to next line or close + self._line_idx += 1 + if self._line_idx < len(self._lines): + self._text_box = self._make_box() + else: + self.game.pop_state() + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + + def update(self, dt: float): + if self._text_box: + self._text_box.update(dt) + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface): + # Background: last rendered frame of the state below + surface.blit(self._background, (0, 0)) + + # Semi-transparent overlay to dim the background + overlay = pygame.Surface((NATIVE_W, NATIVE_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 100)) + surface.blit(overlay, (0, 0)) + + if self._text_box: + self._text_box.draw(surface) + + # NPC name tag above the text box + name_surf = self._font_name.render(self._npc_name, True, YELLOW) + surface.blit(name_surf, (10, NATIVE_H - 72)) + + # Advance hint (show when text is fully revealed) + if self._text_box and self._text_box.is_complete: + hint = self._font_name.render("ENTER/Z to continue", True, (160, 160, 160)) + surface.blit(hint, hint.get_rect(right=NATIVE_W - 10, bottom=NATIVE_H - 4)) diff --git a/src/states/game_over.py b/src/states/game_over.py new file mode 100644 index 0000000..d2aea73 --- /dev/null +++ b/src/states/game_over.py @@ -0,0 +1,28 @@ +"""Game-Over state.""" +import pygame +from src.states.base_state import BaseState +from settings import WHITE, RED, NATIVE_W, NATIVE_H + + +class GameOverState(BaseState): + def enter(self, **kwargs): + self._font = pygame.font.SysFont("monospace", 14, bold=True) + self._sub = pygame.font.SysFont("monospace", 8) + + def exit(self): + pass + + def handle_input(self, event): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_RETURN: + self.game.change_state("title") + + def update(self, dt: float): + pass + + def draw(self, surface: pygame.Surface): + surface.fill((140, 0, 0)) + lbl = self._font.render("GAME OVER", True, WHITE) + surface.blit(lbl, lbl.get_rect(center=(NATIVE_W // 2, NATIVE_H // 2 - 10))) + hint = self._sub.render("Press ENTER to return to title", True, WHITE) + surface.blit(hint, hint.get_rect(center=(NATIVE_W // 2, NATIVE_H // 2 + 16))) diff --git a/src/states/overworld.py b/src/states/overworld.py new file mode 100644 index 0000000..ddcc1e0 --- /dev/null +++ b/src/states/overworld.py @@ -0,0 +1,110 @@ +"""Overworld exploration state.""" +import json +import random +import pygame +from src.states.base_state import BaseState +from src.utils.tilemap import TileMap +from src.entities.player import Player +from src.entities.enemy import Enemy +from src.systems.camera import Camera +from src.systems.encounter import EncounterSystem +from settings import ( + NATIVE_W, NATIVE_H, TILE_SIZE, WHITE, BLACK, + ENCOUNTER_STEP_MIN, ENCOUNTER_STEP_MAX, +) + +MAP_PATH = "assets/maps/overworld.txt" +ENEMIES_PATH = "data/enemies.json" +ENCOUNTERS_PATH = "data/encounters.json" + + +class OverworldState(BaseState): + def enter(self, **kwargs): + self.tilemap = TileMap(MAP_PATH) + col, row = self.tilemap.player_spawn + self.player = Player(col, row) + self.camera = Camera() + + # ------------------------------------------------------------------ + # Encounter system + # ------------------------------------------------------------------ + self._encounter = EncounterSystem(ENCOUNTER_STEP_MIN, ENCOUNTER_STEP_MAX) + try: + self._encounter.load_encounters(ENCOUNTERS_PATH) + except Exception as exc: + print(f"[Overworld] Could not load encounters: {exc}") + + # Pre-load enemy data for quick lookup + self._enemy_data: dict[str, dict] = {} + try: + with open(ENEMIES_PATH, "r", encoding="utf-8") as fh: + for entry in json.load(fh)["enemies"]: + self._enemy_data[entry["id"]] = entry + except Exception as exc: + print(f"[Overworld] Could not load enemy data: {exc}") + + self._font = pygame.font.SysFont("monospace", 8) + + def exit(self): + pass + + def handle_input(self, event): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self.game.change_state("pause_menu") + + def update(self, dt: float): + keys = pygame.key.get_pressed() + distance = self.player.update(dt, keys, self.tilemap.wall_tiles) + + # Camera follows player + self.camera.update( + self.player.center, + self.tilemap.pixel_width, + self.tilemap.pixel_height, + ) + + # Check for town-entrance collision + player_rect = self.player.rect + for tile in self.tilemap.town_entrances: + if player_rect.colliderect(tile.rect): + self.game.change_state("town") + return + + # Accumulate steps only while on encounter tiles + on_encounter = any( + player_rect.colliderect(t.rect) + for t in self.tilemap.encounter_tiles + ) + if on_encounter and distance > 0: + enemy_ids = self._encounter.on_step("grasslands", distance) + if enemy_ids: + enemies = self._build_enemies(enemy_ids) + if enemies: + self.game.change_state( + "battle", player=self.player, enemies=enemies + ) + return + + def _build_enemies(self, enemy_ids: list[str]) -> list: + """Instantiate Enemy objects from a list of IDs.""" + result = [] + for eid in enemy_ids: + data = self._enemy_data.get(eid) + if data: + import copy + entry = copy.deepcopy(data) + entry["hp"] = entry["max_hp"] + result.append(Enemy(entry)) + else: + print(f"[Overworld] Unknown enemy ID: {eid}") + return result + + def draw(self, surface: pygame.Surface): + surface.fill(BLACK) + self.tilemap.draw(surface, self.camera.offset) + self.player.draw(surface, self.camera.offset) + + # HUD label + label = self._font.render("OVERWORLD", True, WHITE) + surface.blit(label, (4, 4)) diff --git a/src/states/pause_menu.py b/src/states/pause_menu.py new file mode 100644 index 0000000..1076723 --- /dev/null +++ b/src/states/pause_menu.py @@ -0,0 +1,356 @@ +"""Pause menu state – Phase 3 full implementation.""" +from __future__ import annotations + +import pygame +from src.states.base_state import BaseState +from src.systems.magic import MagicSystem +from src.systems.inventory import InventorySystem +from settings import BLACK, WHITE, YELLOW, GREEN, GREY, NATIVE_W, NATIVE_H + +_TABS = ["Items", "Equip", "Magic", "Stats", "Save", "Exit"] +_SLOTS = ["weapon", "shield", "helmet", "armor", "accessory"] +_SLOT_LABELS = ["Weapon", "Shield", "Helmet", "Armor", "Acc."] + + +class PauseMenuState(BaseState): + """Full pause menu with Items, Equipment, Magic, Stats, Save, Exit tabs.""" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def enter(self, **kwargs): + self._player = kwargs.get("player") + self._font_lg = pygame.font.SysFont("monospace", 10, bold=True) + self._font_md = pygame.font.SysFont("monospace", 8, bold=True) + self._font_sm = pygame.font.SysFont("monospace", 7) + + # Semi-transparent background + self._overlay = pygame.Surface((NATIVE_W, NATIVE_H), pygame.SRCALPHA) + self._overlay.fill((0, 0, 0, 200)) + + # Systems + self._magic = MagicSystem() + self._inv = InventorySystem() + try: + self._magic.load_spells("data/spells.json") + self._inv.load_items("data/items.json") + except Exception: + pass + + # Navigation + self._tab_idx = 0 # which tab is active + self._cursor = 0 # cursor within tab content + + # Equipment sub-mode: "slots" or "pick_item" + self._equip_mode = "slots" + self._equip_pick_cursor = 0 + self._equip_pick_slot = "" + self._equip_candidates: list[str] = [] # item_ids that fit the slot + + # Save feedback + self._save_msg = "" + + def exit(self): + pass + + # ------------------------------------------------------------------ + # Input + # ------------------------------------------------------------------ + + def handle_input(self, event): + if event.type != pygame.KEYDOWN: + return + key = event.key + + # ESC always closes pause menu + if key == pygame.K_ESCAPE: + self.game.pop_state() + return + + # Left/Right switch tabs (only when not in sub-mode) + if self._equip_mode == "slots" or _TABS[self._tab_idx] != "Equip": + if key in (pygame.K_LEFT,): + self._tab_idx = (self._tab_idx - 1) % len(_TABS) + self._cursor = 0 + return + if key in (pygame.K_RIGHT,): + self._tab_idx = (self._tab_idx + 1) % len(_TABS) + self._cursor = 0 + return + + tab = _TABS[self._tab_idx] + + if tab == "Items": + self._handle_items_input(key) + elif tab == "Equip": + self._handle_equip_input(key) + elif tab == "Magic": + self._handle_magic_input(key) + elif tab == "Stats": + pass # view only + elif tab == "Save": + self._handle_save_input(key) + elif tab == "Exit": + if key in (pygame.K_RETURN, pygame.K_z): + self.game.pop_state() + + def _handle_items_input(self, key: int): + if self._player is None: + return + usable = self._inv.get_usable_items(self._player.inventory, in_battle=False) + if not usable: + return + if key in (pygame.K_UP, pygame.K_w): + self._cursor = (self._cursor - 1) % len(usable) + elif key in (pygame.K_DOWN, pygame.K_s): + self._cursor = (self._cursor + 1) % len(usable) + elif key in (pygame.K_RETURN, pygame.K_z): + entry = usable[self._cursor] + item_id = entry["item_id"] + item = self._inv.items.get(item_id, {}) + itype = item.get("type", "consumable") + if itype == "consumable": + msg = self._inv.use_item( + item_id, self._player, self._player, + self._player.inventory, None, + ) + # Show feedback in save msg area + self._save_msg = msg + + def _handle_equip_input(self, key: int): + if self._player is None: + return + if self._equip_mode == "slots": + if key in (pygame.K_UP, pygame.K_w): + self._cursor = (self._cursor - 1) % len(_SLOTS) + elif key in (pygame.K_DOWN, pygame.K_s): + self._cursor = (self._cursor + 1) % len(_SLOTS) + elif key in (pygame.K_RETURN, pygame.K_z): + slot = _SLOTS[self._cursor] + # Build list of inventory items for this slot + candidates = [ + e["item_id"] for e in self._player.inventory + if self._inv.items.get(e["item_id"], {}).get("slot") == slot + ] + self._equip_candidates = candidates + self._equip_pick_slot = slot + self._equip_pick_cursor = 0 + self._equip_mode = "pick_item" + elif key in (pygame.K_x,): + # Unequip current slot + slot = _SLOTS[self._cursor] + msg = self._inv.unequip_item(self._player, slot) + self._save_msg = msg + elif self._equip_mode == "pick_item": + items_for_slot = self._equip_candidates + if not items_for_slot: + self._equip_mode = "slots" + return + if key in (pygame.K_UP, pygame.K_w): + self._equip_pick_cursor = (self._equip_pick_cursor - 1) % len(items_for_slot) + elif key in (pygame.K_DOWN, pygame.K_s): + self._equip_pick_cursor = (self._equip_pick_cursor + 1) % len(items_for_slot) + elif key in (pygame.K_RETURN, pygame.K_z): + item_id = items_for_slot[self._equip_pick_cursor] + msg = self._inv.equip_item(self._player, item_id) + self._save_msg = msg + self._equip_mode = "slots" + elif key in (pygame.K_ESCAPE, pygame.K_x): + self._equip_mode = "slots" + + def _handle_magic_input(self, key: int): + if self._player is None: + return + spells = getattr(self._player, "spells", []) + if not spells: + return + if key in (pygame.K_UP, pygame.K_w): + self._cursor = (self._cursor - 1) % len(spells) + elif key in (pygame.K_DOWN, pygame.K_s): + self._cursor = (self._cursor + 1) % len(spells) + + def _handle_save_input(self, key: int): + if key in (pygame.K_RETURN, pygame.K_z): + # Stub: simulate save + self._save_msg = "Game saved! (stub)" + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + + def update(self, dt: float): + pass + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface): + surface.blit(self._overlay, (0, 0)) + self._draw_tabs(surface) + self._draw_content(surface) + if self._save_msg: + self._draw_feedback(surface) + + def _draw_tabs(self, surface: pygame.Surface): + x = 4 + tab_w = (NATIVE_W - 8) // len(_TABS) + for i, tab in enumerate(_TABS): + tx = x + i * tab_w + if i == self._tab_idx: + pygame.draw.rect(surface, (60, 60, 120), (tx, 4, tab_w - 1, 12)) + color = YELLOW + else: + pygame.draw.rect(surface, (30, 30, 60), (tx, 4, tab_w - 1, 12)) + color = WHITE + pygame.draw.rect(surface, (180, 180, 200), (tx, 4, tab_w - 1, 12), 1) + lbl = self._font_sm.render(tab, True, color) + surface.blit(lbl, lbl.get_rect(centerx=tx + tab_w // 2, y=7)) + + def _draw_content(self, surface: pygame.Surface): + # Content area + cx, cy = 4, 20 + cw, ch = NATIVE_W - 8, NATIVE_H - 30 + pygame.draw.rect(surface, (15, 15, 35), (cx, cy, cw, ch)) + pygame.draw.rect(surface, (100, 100, 160), (cx, cy, cw, ch), 1) + + tab = _TABS[self._tab_idx] + if tab == "Items": + self._draw_items_tab(surface, cx + 3, cy + 3, cw - 6, ch - 6) + elif tab == "Equip": + self._draw_equip_tab(surface, cx + 3, cy + 3, cw - 6, ch - 6) + elif tab == "Magic": + self._draw_magic_tab(surface, cx + 3, cy + 3, cw - 6, ch - 6) + elif tab == "Stats": + self._draw_stats_tab(surface, cx + 3, cy + 3, cw - 6, ch - 6) + elif tab == "Save": + self._draw_save_tab(surface, cx + 3, cy + 3, cw - 6, ch - 6) + elif tab == "Exit": + self._draw_exit_tab(surface, cx + 3, cy + 3, cw - 6, ch - 6) + + def _draw_items_tab(self, surface, x, y, w, h): + if self._player is None: + return + lbl = self._font_md.render("Items", True, YELLOW) + surface.blit(lbl, (x, y)) + usable = self._inv.get_usable_items(self._player.inventory, in_battle=False) + if not usable: + s = self._font_sm.render("Inventory empty.", True, GREY) + surface.blit(s, (x, y + 14)) + return + for i, entry in enumerate(usable[:16]): + iy = y + 14 + i * 9 + item = self._inv.items.get(entry["item_id"], {}) + label = f"{item.get('name', entry['item_id']):<16} x{entry['qty']}" + color = YELLOW if i == self._cursor else WHITE + if i == self._cursor: + pygame.draw.rect(surface, (40, 40, 80), (x, iy, w, 9)) + s = self._font_sm.render(label[:30], True, color) + surface.blit(s, (x + 2, iy + 1)) + + def _draw_equip_tab(self, surface, x, y, w, h): + if self._player is None: + return + lbl = self._font_md.render("Equipment", True, YELLOW) + surface.blit(lbl, (x, y)) + + if self._equip_mode == "slots": + for i, (slot, slabel) in enumerate(zip(_SLOTS, _SLOT_LABELS)): + iy = y + 14 + i * 10 + equipped_id = getattr(self._player, slot, None) + equip_name = (self._inv.items.get(equipped_id, {}).get("name", equipped_id) + if equipped_id else "---") + label = f"{slabel:<6}: {equip_name}" + color = YELLOW if i == self._cursor else WHITE + if i == self._cursor: + pygame.draw.rect(surface, (40, 40, 80), (x, iy, w, 10)) + s = self._font_sm.render(label[:30], True, color) + surface.blit(s, (x + 2, iy + 1)) + hint = self._font_sm.render("ENTER:equip X:unequip", True, GREY) + surface.blit(hint, (x, y + 14 + len(_SLOTS) * 10 + 4)) + + elif self._equip_mode == "pick_item": + slot_lbl = _SLOT_LABELS[_SLOTS.index(self._equip_pick_slot)] + hdr = self._font_sm.render(f"Select {slot_lbl} (ESC:cancel)", True, YELLOW) + surface.blit(hdr, (x, y + 14)) + if not self._equip_candidates: + s = self._font_sm.render("No compatible items.", True, GREY) + surface.blit(s, (x, y + 26)) + return + for i, item_id in enumerate(self._equip_candidates[:14]): + iy = y + 26 + i * 9 + item = self._inv.items.get(item_id, {}) + stats = item.get("stats", {}) or {} + stat_str = ", ".join(f"{k.upper()}+{v}" for k, v in stats.items()) + label = f"{item.get('name', item_id):<14} {stat_str}" + color = YELLOW if i == self._equip_pick_cursor else WHITE + if i == self._equip_pick_cursor: + pygame.draw.rect(surface, (40, 40, 80), (x, iy, w, 9)) + s = self._font_sm.render(label[:30], True, color) + surface.blit(s, (x + 2, iy + 1)) + + def _draw_magic_tab(self, surface, x, y, w, h): + if self._player is None: + return + lbl = self._font_md.render("Magic", True, YELLOW) + surface.blit(lbl, (x, y)) + spells = getattr(self._player, "spells", []) + if not spells: + s = self._font_sm.render("No spells known.", True, GREY) + surface.blit(s, (x, y + 14)) + return + for i, spell_id in enumerate(spells[:18]): + iy = y + 14 + i * 9 + spell = self._magic.spells.get(spell_id) + if spell: + label = f"{spell['name']:<12} MP{spell['mp_cost']:2d}" + else: + label = spell_id + color = YELLOW if i == self._cursor else WHITE + if i == self._cursor: + pygame.draw.rect(surface, (40, 40, 80), (x, iy, w, 9)) + # Show description if available + if spell: + desc = (spell.get("description") or "")[:32] + ds = self._font_sm.render(desc, True, GREY) + surface.blit(ds, (x, y + 14 + len(spells[:18]) * 9 + 6)) + s = self._font_sm.render(label[:30], True, color) + surface.blit(s, (x + 2, iy + 1)) + + def _draw_stats_tab(self, surface, x, y, w, h): + if self._player is None: + return + p = self._player + lbl = self._font_md.render("Status", True, YELLOW) + surface.blit(lbl, (x, y)) + lines = [ + f"Name : {p.name}", + f"Level: {p.level} EXP: {p.exp}", + f"HP : {p.hp}/{p.max_hp}", + f"MP : {p.mp}/{p.max_mp}", + f"STR : {p.str_} DEF: {p.def_}", + f"MAG : {p.mag} MDF: {p.mdf}", + f"SPD : {p.spd} LCK: {p.lck}", + f"Gold : {p.gold}", + f"ATK : {p.get_attack_power()} ARMOR: {p.get_armor_defense()}", + ] + for i, line in enumerate(lines): + s = self._font_sm.render(line, True, WHITE) + surface.blit(s, (x, y + 14 + i * 10)) + + def _draw_save_tab(self, surface, x, y, w, h): + lbl = self._font_md.render("Save Game", True, YELLOW) + surface.blit(lbl, (x, y)) + hint = self._font_sm.render("Press ENTER to save game", True, WHITE) + surface.blit(hint, (x, y + 30)) + + def _draw_exit_tab(self, surface, x, y, w, h): + lbl = self._font_md.render("Exit", True, YELLOW) + surface.blit(lbl, (x, y)) + hint = self._font_sm.render("Press ENTER to return", True, WHITE) + surface.blit(hint, (x, y + 30)) + + def _draw_feedback(self, surface: pygame.Surface): + s = self._font_sm.render(self._save_msg[:38], True, GREEN) + surface.blit(s, s.get_rect(centerx=NATIVE_W // 2, bottom=NATIVE_H - 4)) diff --git a/src/states/shop.py b/src/states/shop.py new file mode 100644 index 0000000..6ef169c --- /dev/null +++ b/src/states/shop.py @@ -0,0 +1,290 @@ +"""Shop state – Phase 4 full implementation.""" +from __future__ import annotations + +import json +import pygame +from src.states.base_state import BaseState +from settings import ( + WHITE, YELLOW, BLACK, GREEN, GREY, RED, NATIVE_W, NATIVE_H, +) + +SHOPS_PATH = "data/shops.json" +ITEMS_PATH = "data/items.json" + +_TAB_BUY = "BUY" +_TAB_SELL = "SELL" + + +class ShopState(BaseState): + """Two-panel shop: BUY from shop inventory, SELL from player inventory.""" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def enter(self, **kwargs): + self._shop_id = kwargs.get("shop_id", "") + self._player = kwargs.get("player") + + # Load data + self._shop_data: dict = {} + self._items_data: dict[str, dict] = {} + self._load_data() + + self._tab = _TAB_BUY + self._buy_cursor = 0 + self._sell_cursor= 0 + self._message = "" + self._msg_timer = 0.0 + + self._font_lg = pygame.font.SysFont("monospace", 9, bold=True) + self._font_md = pygame.font.SysFont("monospace", 8, bold=True) + self._font_sm = pygame.font.SysFont("monospace", 7) + + def exit(self): + pass + + # ------------------------------------------------------------------ + # Data helpers + # ------------------------------------------------------------------ + + def _load_data(self) -> None: + try: + with open(SHOPS_PATH, "r", encoding="utf-8") as fh: + self._shop_data = json.load(fh).get(self._shop_id, {}) + except Exception as exc: + print(f"[Shop] Cannot load shops: {exc}") + try: + with open(ITEMS_PATH, "r", encoding="utf-8") as fh: + raw = json.load(fh) + self._items_data = {item["id"]: item for item in raw["items"]} + except Exception as exc: + print(f"[Shop] Cannot load items: {exc}") + + @property + def _shop_item_ids(self) -> list[str]: + return self._shop_data.get("items", []) + + @property + def _player_inv(self) -> list[dict]: + return self._player.inventory if self._player else [] + + def _item(self, item_id: str) -> dict: + return self._items_data.get(item_id, {"name": item_id, "price": 0, "description": ""}) + + def _sell_price(self, item_id: str) -> int: + ratio = self._shop_data.get("sell_ratio", 0.5) + return max(1, int(self._item(item_id).get("price", 0) * ratio)) + + def _is_equipped(self, item_id: str) -> bool: + if not self._player: + return False + slots = ["weapon", "shield", "helmet", "armor", "accessory"] + return any(getattr(self._player, s, None) == item_id for s in slots) + + # ------------------------------------------------------------------ + # Input + # ------------------------------------------------------------------ + + def handle_input(self, event): + if event.type != pygame.KEYDOWN: + return + key = event.key + + if key == pygame.K_ESCAPE or key == pygame.K_x: + self.game.pop_state() + return + + # Tab switch + if key in (pygame.K_LEFT, pygame.K_RIGHT): + self._tab = _TAB_SELL if self._tab == _TAB_BUY else _TAB_BUY + return + + if self._tab == _TAB_BUY: + self._handle_buy_input(key) + else: + self._handle_sell_input(key) + + def _handle_buy_input(self, key: int) -> None: + ids = self._shop_item_ids + if not ids: + return + if key in (pygame.K_UP, pygame.K_w): + self._buy_cursor = (self._buy_cursor - 1) % len(ids) + elif key in (pygame.K_DOWN, pygame.K_s): + self._buy_cursor = (self._buy_cursor + 1) % len(ids) + elif key in (pygame.K_RETURN, pygame.K_z): + self._do_buy(ids[self._buy_cursor]) + + def _handle_sell_input(self, key: int) -> None: + inv = self._player_inv + if not inv: + return + if key in (pygame.K_UP, pygame.K_w): + self._sell_cursor = (self._sell_cursor - 1) % len(inv) + elif key in (pygame.K_DOWN, pygame.K_s): + self._sell_cursor = (self._sell_cursor + 1) % len(inv) + elif key in (pygame.K_RETURN, pygame.K_z): + if self._sell_cursor < len(inv): + self._do_sell(inv[self._sell_cursor]["item_id"]) + + def _do_buy(self, item_id: str) -> None: + item = self._item(item_id) + price = item.get("price", 0) + if not self._player: + return + if self._player.gold < price: + self._flash("Not enough gold!") + return + self._player.gold -= price + # Add to inventory + for entry in self._player.inventory: + if entry["item_id"] == item_id: + entry["qty"] = min(99, entry["qty"] + 1) + break + else: + self._player.inventory.append({"item_id": item_id, "qty": 1}) + self._flash(f"Bought {item.get('name', item_id)}!") + + def _do_sell(self, item_id: str) -> None: + if self._is_equipped(item_id): + self._flash("Unequip it first!") + return + sell = self._sell_price(item_id) + item = self._item(item_id) + inv = self._player_inv + for entry in inv: + if entry["item_id"] == item_id: + entry["qty"] -= 1 + if entry["qty"] <= 0: + inv.remove(entry) + if self._sell_cursor >= len(inv): + self._sell_cursor = max(0, len(inv) - 1) + self._player.gold += sell # type: ignore[union-attr] + self._flash(f"Sold {item.get('name', item_id)} +{sell}G") + return + self._flash("Item not found.") + + def _flash(self, msg: str) -> None: + self._message = msg + self._msg_timer = 2.0 + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + + def update(self, dt: float): + if self._msg_timer > 0: + self._msg_timer -= dt + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface): + surface.fill((15, 15, 35)) + + # ── Title bar ──────────────────────────────────────────────── + shop_name = self._shop_data.get("name", self._shop_id) + title = self._font_lg.render(shop_name, True, YELLOW) + surface.blit(title, title.get_rect(centerx=NATIVE_W // 2, y=2)) + + # ── Tab buttons ────────────────────────────────────────────── + for i, tab in enumerate([_TAB_BUY, _TAB_SELL]): + tx = 30 + i * 90 + active = (tab == self._tab) + col = (60, 60, 120) if active else (30, 30, 60) + pygame.draw.rect(surface, col, (tx, 14, 60, 12)) + pygame.draw.rect(surface, (180, 180, 200), (tx, 14, 60, 12), 1) + tc = YELLOW if active else WHITE + ts = self._font_sm.render(tab, True, tc) + surface.blit(ts, ts.get_rect(centerx=tx + 30, centery=20)) + + # ── Panels ─────────────────────────────────────────────────── + panel_y = 28 + panel_h = NATIVE_H - 28 - 30 + mid_x = NATIVE_W // 2 + + # Left panel: shop inventory + pygame.draw.rect(surface, (20, 20, 50), (0, panel_y, mid_x - 1, panel_h)) + pygame.draw.rect(surface, (80, 80, 120), (0, panel_y, mid_x - 1, panel_h), 1) + lhdr = self._font_sm.render("SHOP", True, (180, 180, 220)) + surface.blit(lhdr, (4, panel_y + 2)) + self._draw_shop_list(surface, 4, panel_y + 12, mid_x - 8, panel_h - 14) + + # Right panel: player inventory + pygame.draw.rect(surface, (20, 20, 50), (mid_x + 1, panel_y, mid_x - 1, panel_h)) + pygame.draw.rect(surface, (80, 80, 120), (mid_x + 1, panel_y, mid_x - 1, panel_h), 1) + rhdr = self._font_sm.render("INVENTORY", True, (180, 180, 220)) + surface.blit(rhdr, (mid_x + 4, panel_y + 2)) + self._draw_player_list(surface, mid_x + 4, panel_y + 12, mid_x - 8, panel_h - 14) + + # ── Bottom bar ─────────────────────────────────────────────── + by = NATIVE_H - 28 + pygame.draw.rect(surface, (10, 10, 30), (0, by, NATIVE_W, 28)) + pygame.draw.rect(surface, (80, 80, 120), (0, by, NATIVE_W, 28), 1) + gold_txt = f"Gold: {self._player.gold if self._player else 0}" + surface.blit(self._font_sm.render(gold_txt, True, YELLOW), (4, by + 3)) + + # Description of selected item + desc = self._selected_description() + if desc: + ds = self._font_sm.render(desc[:40], True, WHITE) + surface.blit(ds, (4, by + 14)) + + # Flash message + if self._msg_timer > 0: + ms = self._font_sm.render(self._message, True, GREEN) + surface.blit(ms, ms.get_rect(right=NATIVE_W - 4, y=by + 3)) + + # Controls hint + hint = self._font_sm.render("L/R:tab UP/DN:nav Z:confirm ESC:exit", True, GREY) + surface.blit(hint, hint.get_rect(centerx=NATIVE_W // 2, bottom=NATIVE_H - 1)) + + def _draw_shop_list(self, surface, x, y, w, h) -> None: + ids = self._shop_item_ids + active = (self._tab == _TAB_BUY) + for i, iid in enumerate(ids): + iy = y + i * 9 + if iy + 9 > y + h: + break + item = self._item(iid) + name = item.get("name", iid)[:12] + price = item.get("price", 0) + label = f"{name:<12} {price:>4}G" + sel = active and (i == self._buy_cursor) + if sel: + pygame.draw.rect(surface, (50, 50, 100), (x, iy, w, 9)) + color = YELLOW if sel else WHITE + surface.blit(self._font_sm.render(label, True, color), (x + 1, iy + 1)) + + def _draw_player_list(self, surface, x, y, w, h) -> None: + inv = self._player_inv + active = (self._tab == _TAB_SELL) + for i, entry in enumerate(inv): + iy = y + i * 9 + if iy + 9 > y + h: + break + iid = entry["item_id"] + item = self._item(iid) + name = item.get("name", iid)[:10] + qty = entry["qty"] + sp = self._sell_price(iid) + eqp = " E" if self._is_equipped(iid) else "" + label = f"{name:<10}x{qty} {sp}G{eqp}" + sel = active and (i == self._sell_cursor) + if sel: + pygame.draw.rect(surface, (50, 50, 100), (x, iy, w, 9)) + color = (150, 150, 150) if self._is_equipped(iid) else (YELLOW if sel else WHITE) + surface.blit(self._font_sm.render(label[:22], True, color), (x + 1, iy + 1)) + + def _selected_description(self) -> str: + if self._tab == _TAB_BUY: + ids = self._shop_item_ids + if ids and 0 <= self._buy_cursor < len(ids): + return self._item(ids[self._buy_cursor]).get("description", "") + else: + inv = self._player_inv + if inv and 0 <= self._sell_cursor < len(inv): + return self._item(inv[self._sell_cursor]["item_id"]).get("description", "") + return "" diff --git a/src/states/title.py b/src/states/title.py new file mode 100644 index 0000000..894ccc3 --- /dev/null +++ b/src/states/title.py @@ -0,0 +1,50 @@ +"""Title / main-menu screen.""" +import pygame +from src.states.base_state import BaseState +from settings import NATIVE_W, NATIVE_H, WHITE, DARK_GREEN, YELLOW, GREY + + +class TitleState(BaseState): + def enter(self, **kwargs): + self._alpha = 0 # used for a simple blink on the subtitle + self._blink_timer = 0.0 + + def exit(self): + pass + + def handle_input(self, event): + if event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN: + self.game.change_state("overworld") + + def update(self, dt: float): + self._blink_timer += dt + if self._blink_timer >= 0.6: + self._blink_timer = 0.0 + self._alpha = 255 if self._alpha == 0 else 0 + + def draw(self, surface): + surface.fill(DARK_GREEN) + + # Draw a slightly lighter horizontal band for depth + band = pygame.Surface((NATIVE_W, 60)) + band.fill((30, 100, 30)) + surface.blit(band, (0, NATIVE_H // 2 - 30)) + + # Title text + font_big = pygame.font.SysFont("monospace", 14, bold=True) + font_sm = pygame.font.SysFont("monospace", 8) + + title_surf = font_big.render("POST-PANDEMIC FANTASY", True, YELLOW) + title_rect = title_surf.get_rect(center=(NATIVE_W // 2, NATIVE_H // 2 - 10)) + surface.blit(title_surf, title_rect) + + # Blinking subtitle + if self._alpha > 0: + sub_surf = font_sm.render("Press ENTER to Start", True, WHITE) + sub_rect = sub_surf.get_rect(center=(NATIVE_W // 2, NATIVE_H // 2 + 20)) + surface.blit(sub_surf, sub_rect) + + # Footer + footer = font_sm.render("(c) 2024 arrow keys / WASD to move", True, GREY) + footer_rect = footer.get_rect(center=(NATIVE_W // 2, NATIVE_H - 12)) + surface.blit(footer, footer_rect) diff --git a/src/states/town.py b/src/states/town.py new file mode 100644 index 0000000..b6ec15d --- /dev/null +++ b/src/states/town.py @@ -0,0 +1,269 @@ +"""Town exploration state – Phase 4 full implementation.""" +from __future__ import annotations + +import pygame +from src.states.base_state import BaseState +from src.utils.tilemap import TileMap +from src.entities.player import Player +from src.entities.npc import NPC +from src.systems.camera import Camera +from src.ui.hud import HUD +from settings import ( + BLACK, WHITE, YELLOW, NATIVE_W, NATIVE_H, TILE_SIZE, +) + +# Per-town NPC character → npc_id mapping +_NPC_MAPS: dict[str, dict[str, str]] = { + "ashenvale": { + "1": "ashenvale_elder", + "2": "ashenvale_merchant", + "3": "ashenvale_innkeeper", + "4": "tutorial_npc", + }, + "ironhaven": { + "1": "ironhaven_blacksmith", + "2": "ironhaven_innkeeper", + }, + "willowmere": { + "1": "willowmere_healer", + "2": "willowmere_merchant", + }, +} + +_INN_COSTS: dict[str, int] = { + "ashenvale": 50, + "ironhaven": 100, + "willowmere": 75, +} + +_TOWN_NAMES: dict[str, str] = { + "ashenvale": "Ashenvale", + "ironhaven": "Ironhaven", + "willowmere": "Willowmere", +} + + +class TownState(BaseState): + """Full town state: tilemap, NPCs, shops, inn, camera, HUD.""" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def enter(self, **kwargs): + self._town_id = kwargs.get("town_id", "ashenvale") + self._map_path = kwargs.get("map_path", f"assets/maps/{self._town_id}.txt") + self._player: Player = kwargs.get("player") # type: ignore[assignment] + + self._tilemap = TileMap(self._map_path) + + # Position or create player at spawn + col, row = self._tilemap.player_spawn + if self._player is None: + self._player = Player(col, row) + else: + self._player.x = float(col * TILE_SIZE) + self._player.y = float(row * TILE_SIZE) + self._player.rect.topleft = (int(self._player.x), int(self._player.y)) + + # Parse NPCs and special tiles from raw map + self._npcs: list[NPC] = [] + self._shop_rects: list[pygame.Rect] = [] + self._inn_rects: list[pygame.Rect] = [] + self._parse_town_map() + + # Blocking group: walls + NPC sprites (player can't walk through) + self._blocking = pygame.sprite.Group() + for sprite in self._tilemap.wall_tiles: + self._blocking.add(sprite) + for npc in self._npcs: + self._blocking.add(npc) + + self._camera = Camera() + self._font = pygame.font.SysFont("monospace", 8) + self._font_sm = pygame.font.SysFont("monospace", 7) + + # Inn prompt state + self._inn_active = False + self._inn_cursor = 0 # 0 = Yes, 1 = No + self._inn_msg = "" + self._inn_msg_timer = 0.0 + + # Cooldown prevents immediate re-trigger when returning from shop/dialog + self._trigger_cooldown = 0.5 + + # HUD + self._hud = HUD() + self._hud.set_area_name(_TOWN_NAMES.get(self._town_id, self._town_id.title())) + + def exit(self): + pass + + # ------------------------------------------------------------------ + # Map parsing + # ------------------------------------------------------------------ + + def _parse_town_map(self) -> None: + try: + with open(self._map_path, "r") as fh: + lines = fh.readlines() + except Exception as exc: + print(f"[Town] Cannot parse map: {exc}") + return + + npc_map = _NPC_MAPS.get(self._town_id, {}) + for row, line in enumerate(lines): + line = line.rstrip("\n") + for col, ch in enumerate(line): + if ch.isdigit() and ch != "0": + npc_id = npc_map.get(ch) + if npc_id: + self._npcs.append(NPC(npc_id, col, row, TILE_SIZE)) + elif ch == "S": + self._shop_rects.append( + pygame.Rect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE) + ) + elif ch == "I": + self._inn_rects.append( + pygame.Rect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE) + ) + + # ------------------------------------------------------------------ + # Input + # ------------------------------------------------------------------ + + def handle_input(self, event): + if event.type != pygame.KEYDOWN: + return + key = event.key + + # Inn yes/no prompt captures all input + if self._inn_active: + if key in (pygame.K_LEFT, pygame.K_RIGHT, pygame.K_a, pygame.K_d): + self._inn_cursor = 1 - self._inn_cursor + elif key in (pygame.K_RETURN, pygame.K_z): + self._resolve_inn() + elif key == pygame.K_ESCAPE: + self._inn_active = False + return + + if key == pygame.K_ESCAPE: + self.game.push_state("pause_menu", player=self._player) + elif key in (pygame.K_RETURN, pygame.K_z): + self._try_interact() + + def _try_interact(self) -> None: + for npc in self._npcs: + if npc.get_interaction_rect().colliderect(self._player.rect): + self.game.push_state( + "dialog", + npc_id=npc.npc_id, + player=self._player, + ) + self._trigger_cooldown = 0.3 + return + + def _resolve_inn(self) -> None: + self._inn_active = False + if self._inn_cursor == 0: # Yes + cost = _INN_COSTS.get(self._town_id, 50) + if self._player.gold >= cost: + self._player.gold -= cost + self._player.hp = self._player.max_hp + self._player.mp = self._player.max_mp + self._inn_msg = f"You rested well! (-{cost}G)" + else: + self._inn_msg = "Not enough gold!" + self._inn_msg_timer = 2.5 + self._trigger_cooldown = 0.5 + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + + def update(self, dt: float): + if self._inn_active: + return + + if self._inn_msg_timer > 0: + self._inn_msg_timer -= dt + + if self._trigger_cooldown > 0: + self._trigger_cooldown -= dt + + keys = pygame.key.get_pressed() + self._player.update(dt, keys, self._blocking) + + self._camera.update( + self._player.center, + self._tilemap.pixel_width, + self._tilemap.pixel_height, + ) + + if self._trigger_cooldown <= 0: + # Shop trigger + for r in self._shop_rects: + if self._player.rect.colliderect(r): + self._trigger_cooldown = 0.5 + self.game.push_state( + "shop", + shop_id=f"{self._town_id}_shop", + player=self._player, + ) + return + + # Inn trigger + for r in self._inn_rects: + if self._player.rect.colliderect(r): + self._inn_active = True + self._inn_cursor = 0 + return + + # Exit: player walks into bottom-row floor gap + p_row = int(self._player.y) // TILE_SIZE + if p_row >= self._tilemap.rows - 1: + p_col = int(self._player.x) // TILE_SIZE + tile = self._tilemap.get_tile_at(p_col, p_row) + if tile and tile.tile_type == "floor": + self.game.change_state("overworld", player=self._player) + return + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface): + surface.fill(BLACK) + self._tilemap.draw(surface, self._camera.offset) + + for npc in self._npcs: + npc.draw(surface, self._camera.offset) + + self._player.draw(surface, self._camera.offset) + + self._hud.draw(surface, self._player) + + if self._inn_active: + self._draw_inn_prompt(surface) + + if self._inn_msg_timer > 0: + ms = self._font_sm.render(self._inn_msg, True, YELLOW) + surface.blit(ms, ms.get_rect(centerx=NATIVE_W // 2, bottom=NATIVE_H - 4)) + + def _draw_inn_prompt(self, surface: pygame.Surface) -> None: + cost = _INN_COSTS.get(self._town_id, 50) + box = pygame.Rect(20, 148, 216, 52) + pygame.draw.rect(surface, (10, 10, 30), box) + pygame.draw.rect(surface, (180, 180, 200), box, 1) + + txt = self._font.render(f"Rest here for {cost} gold?", True, WHITE) + surface.blit(txt, (box.x + 6, box.y + 8)) + + options = ["Yes", "No"] + for i, opt in enumerate(options): + ox = box.x + 50 + i * 70 + oy = box.y + 30 + color = YELLOW if i == self._inn_cursor else WHITE + if i == self._inn_cursor: + pygame.draw.rect(surface, (60, 60, 120), (ox - 3, oy - 2, 32, 14)) + surface.blit(self._font.render(opt, True, color), (ox, oy)) diff --git a/src/systems/__init__.py b/src/systems/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/systems/battle_engine.py b/src/systems/battle_engine.py new file mode 100644 index 0000000..abeee2d --- /dev/null +++ b/src/systems/battle_engine.py @@ -0,0 +1,365 @@ +""" +Battle engine: turn-order, damage formulas, action resolution. +Operates purely on data; no pygame rendering. +""" +from __future__ import annotations + +import random +from dataclasses import dataclass, field + +# Damage tuning constants +_CRIT_DIVISOR = 256 # critical chance = lck / CRIT_DIVISOR (max lck ~30 → ~12 % crit) +_VARIANCE_MIN = 0.9 # damage variance lower bound (±10 %) +_VARIANCE_MAX = 1.1 # damage variance upper bound + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class BattleState: + """Snapshot of the current battle's mutable state.""" + player: object # Player instance + enemies: list # list[Enemy] + turn_order: list = field(default_factory=list) + current_turn: int = 0 + phase: str = "player_input" # see module docstring + log: list = field(default_factory=list) + turn_number: int = 0 + + +@dataclass +class BattleResult: + """Result returned from execute_action.""" + damage: int = 0 + is_critical: bool = False + status_applied: str | None = None + message: str = "" + target_fainted: bool = False + + +# --------------------------------------------------------------------------- +# Engine +# --------------------------------------------------------------------------- + +class BattleEngine: + """ + Stateless helper that operates on BattleState objects. + + Damage formulas + --------------- + Physical : max(1, (attacker.str_ * attack_power // 2) - (target.effective_def * armor_defense // 4)) + Magical : max(1, (caster.mag * spell_power) - (target.effective_mdf * resist // 4)) + Critical : 2× damage, chance = lck / 256 + Variance : ±10 % + Elemental: weakness → ×2, resistance → ×0.5, immunity → 0 (magic only) + """ + + # Default weapon/armour ratings used when no equipment is equipped + DEFAULT_ATTACK_POWER = 10 + DEFAULT_ARMOR_DEFENSE = 10 + DEFAULT_SPELL_POWER = 12 + DEFAULT_RESIST = 10 + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def start_battle(self, player, enemies: list) -> BattleState: + state = BattleState(player=player, enemies=list(enemies)) + state.turn_order = self.get_turn_order(state) + state.phase = "player_input" + state.log = [f"A wild {enemies[0].name} appeared!" if len(enemies) == 1 + else f"{len(enemies)} enemies appeared!"] + return state + + # ------------------------------------------------------------------ + # Turn order + # ------------------------------------------------------------------ + + def get_turn_order(self, state: BattleState) -> list: + """ + Returns combatants sorted by effective_spd descending. + Ties: player side goes first. + """ + combatants: list[tuple[int, int, object]] = [] + + # Player is alive check + if state.player.is_alive: + spd = getattr(state.player, "effective_spd", state.player.spd) + # side=0 → player (sorts first on tie) + combatants.append((spd, 0, state.player)) + + for enemy in state.enemies: + if enemy.is_alive: + combatants.append((enemy.effective_spd, 1, enemy)) + + # Sort: higher spd first; on tie side=0 (player) wins + combatants.sort(key=lambda t: (-t[0], t[1])) + return [c[2] for c in combatants] + + # ------------------------------------------------------------------ + # Action resolution + # ------------------------------------------------------------------ + + def execute_action(self, actor, action: dict, state: BattleState) -> BattleResult: + """ + Process one action. + action = {"type": "attack"|"skill"|"flee"|"defend", + "target_idx": int, # index into enemies list (-1 = self) + "spell_id": str|None, + "element": str|None} + """ + atype = action.get("type", "attack") + + if atype == "attack": + return self._resolve_physical(actor, action, state) + + if atype == "skill": + return self._resolve_skill(actor, action, state) + + if atype == "defend": + return self._resolve_defend(actor, state) + + if atype == "flee": + return self._resolve_flee(actor, state) + + return BattleResult(message="Nothing happened.") + + # ------------------------------------------------------------------ + # Physical attack + # ------------------------------------------------------------------ + + def _resolve_physical(self, actor, action: dict, state: BattleState) -> BattleResult: + target = self._get_target(actor, action, state) + if target is None: + return BattleResult(message="No target!") + + # Skip turn if actor is asleep + if getattr(actor, "status_effects", {}).get("sleep", 0) > 0: + return BattleResult(message=f"{actor.name} is asleep!") + + attack_power = getattr(actor, "get_attack_power", lambda: self.DEFAULT_ATTACK_POWER)() + armor_defense = getattr(target, "get_armor_defense", lambda: self.DEFAULT_ARMOR_DEFENSE)() + + # raw = max(1, (actor.str_ * attack_power // 2) - (target.effective_def * armor_defense // 4)) + raw = max(1, + (actor.str_ * attack_power // 2) + - (target.effective_def * armor_defense // 4)) + + # Variance ±10 % + raw = int(raw * random.uniform(_VARIANCE_MIN, _VARIANCE_MAX)) + + # Critical hit chance = lck / CRIT_DIVISOR + is_crit = random.random() < (actor.lck / _CRIT_DIVISOR) + if is_crit: + raw *= 2 + + actual = target.take_damage(raw) + + crit_txt = " Critical hit!" if is_crit else "" + msg = f"{actor.name} attacks {target.name} for {actual} damage!{crit_txt}" + + # Try status infliction based on enemy's status_chance + status_applied = None + for status, chance in getattr(actor, "status_chance", {}).items(): + if random.random() < chance: + if target.apply_status(status, 3): + status_applied = status + msg += f" {target.name} is {status}ed!" + break + + return BattleResult( + damage=actual, + is_critical=is_crit, + status_applied=status_applied, + message=msg, + target_fainted=not target.is_alive, + ) + + # ------------------------------------------------------------------ + # Magical / skill attack + # ------------------------------------------------------------------ + + def _resolve_skill(self, actor, action: dict, state: BattleState) -> BattleResult: + spell_id = action.get("spell_id", "") + element = action.get("element", None) + + # Self-buff skills + if spell_id == "cure_self": + heal = max(1, actor.mag * 2) + actor.hp = min(actor.max_hp, actor.hp + heal) + return BattleResult(message=f"{actor.name} heals itself for {heal}!") + + if spell_id == "haste_self": + actor.apply_status("haste", 4) + return BattleResult(message=f"{actor.name} casts Haste on itself!") + + # Status-infliction skill (enemies that have status_chance) + if spell_id == "status_attack": + target = self._get_target(actor, action, state) + if target is None: + return BattleResult(message="No target!") + status_applied = None + for status, chance in getattr(actor, "status_chance", {}).items(): + if target.apply_status(status, 3): + status_applied = status + break + if status_applied: + return BattleResult( + status_applied=status_applied, + message=f"{actor.name} inflicts {status_applied} on {target.name}!", + ) + return BattleResult(message=f"{actor.name}'s attack had no effect.") + + # Magic attack + target = self._get_target(actor, action, state) + if target is None: + return BattleResult(message="No target!") + + # Silence check + if getattr(actor, "status_effects", {}).get("silence", 0) > 0: + return BattleResult(message=f"{actor.name} is silenced!") + + spell_power = action.get("spell_power", self.DEFAULT_SPELL_POWER) + resist = self.DEFAULT_RESIST + + raw = max(1, (actor.mag * spell_power) - (target.effective_mdf * resist // 4)) + raw = int(raw * random.uniform(_VARIANCE_MIN, _VARIANCE_MAX)) + + # Elemental modifier + elem_mult = 1.0 + if element: + if element in getattr(target, "immunities", []): + return BattleResult(message=f"{target.name} is immune to {element}!") + if element in getattr(target, "weaknesses", []): + elem_mult = 2.0 + elif element in getattr(target, "resistances", []): + elem_mult = 0.5 + raw = max(1, int(raw * elem_mult)) + + is_crit = random.random() < (actor.lck / _CRIT_DIVISOR) + if is_crit: + raw *= 2 + + actual = target.take_damage(raw) + weak_txt = " It's super effective!" if elem_mult == 2.0 else ( + " Not very effective." if elem_mult == 0.5 else "") + crit_txt = " Critical!" if is_crit else "" + msg = f"{actor.name} casts {spell_id} on {target.name} for {actual}!{weak_txt}{crit_txt}" + + return BattleResult( + damage=actual, + is_critical=is_crit, + message=msg, + target_fainted=not target.is_alive, + ) + + # ------------------------------------------------------------------ + # Defend / Flee + # ------------------------------------------------------------------ + + def _resolve_defend(self, actor, state: BattleState) -> BattleResult: + actor.apply_status("protect", 1) + return BattleResult(message=f"{actor.name} defends!") + + def _resolve_flee(self, actor, state: BattleState) -> BattleResult: + chance = self.calculate_flee_chance(state.player, state.enemies) + if random.random() < chance: + state.phase = "fled" + return BattleResult(message="Got away safely!") + return BattleResult(message="Couldn't escape!") + + # ------------------------------------------------------------------ + # Victory / defeat + # ------------------------------------------------------------------ + + def check_victory(self, state: BattleState) -> str | None: + """Returns 'player_win', 'enemy_win', or None (battle ongoing).""" + if not state.player.is_alive: + return "enemy_win" + if all(not e.is_alive for e in state.enemies): + return "player_win" + return None + + # ------------------------------------------------------------------ + # Flee chance + # ------------------------------------------------------------------ + + def calculate_flee_chance(self, player, enemies: list) -> float: + avg_enemy_spd = sum(e.effective_spd for e in enemies) / max(1, len(enemies)) + player_spd = getattr(player, "effective_spd", player.spd) + ratio = player_spd / max(1, avg_enemy_spd) + return min(0.95, max(0.1, ratio * 0.5)) + + # ------------------------------------------------------------------ + # Magic damage (public, used by MagicSystem) + # ------------------------------------------------------------------ + + def magic_damage( + self, + caster, + target, + spell_name: str, + element, + spell_power: int, + state: BattleState, + ) -> BattleResult: + """ + Calculate and apply magic damage from a spell. + Handles elemental modifiers, variance, and critical hits. + Returns a BattleResult. + """ + resist = self.DEFAULT_RESIST + raw = max(1, (caster.mag * spell_power) - (target.effective_mdf * resist // 4)) + raw = int(raw * random.uniform(_VARIANCE_MIN, _VARIANCE_MAX)) + + elem_mult = 1.0 + if element: + if element in getattr(target, "immunities", []): + return BattleResult(message=f"{target.name} is immune to {element}!") + if element in getattr(target, "weaknesses", []): + elem_mult = 2.0 + elif element in getattr(target, "resistances", []): + elem_mult = 0.5 + raw = max(1, int(raw * elem_mult)) + + is_crit = random.random() < (caster.lck / _CRIT_DIVISOR) + if is_crit: + raw *= 2 + + actual = target.take_damage(raw) + weak_txt = (" Super effective!" if elem_mult == 2.0 else + " Not very effective." if elem_mult == 0.5 else "") + crit_txt = " Critical!" if is_crit else "" + msg = f"{caster.name} casts {spell_name} for {actual}!{weak_txt}{crit_txt}" + + return BattleResult( + damage=actual, + is_critical=is_crit, + message=msg, + target_fainted=not target.is_alive, + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_target(self, actor, action: dict, state: BattleState): + """Resolve action target_idx to an actual combatant object.""" + idx = action.get("target_idx", 0) + if idx == -1: + return actor # self-target + + # Determine actor side + is_player_actor = actor is state.player + if is_player_actor: + # Player targets enemies + alive_enemies = [e for e in state.enemies if e.is_alive] + if not alive_enemies: + return None + return alive_enemies[min(idx, len(alive_enemies) - 1)] + else: + # Enemy targets player + return state.player if state.player.is_alive else None diff --git a/src/systems/camera.py b/src/systems/camera.py new file mode 100644 index 0000000..f507cd2 --- /dev/null +++ b/src/systems/camera.py @@ -0,0 +1,38 @@ +"""Camera system: follows a target and clamps to map bounds.""" +import pygame +from settings import NATIVE_W, NATIVE_H + + +class Camera: + """ + Computes the world→screen offset so the player stays centred. + The offset is clamped so the camera never reveals area outside the map. + """ + + def __init__(self): + self.offset = pygame.Vector2(0.0, 0.0) + + def update( + self, + target_center: pygame.Vector2, + map_width_px: int, + map_height_px: int, + ): + """ + Recalculate offset to centre on *target_center*. + + Args: + target_center: world-space centre of the object to follow. + map_width_px: total map width in pixels. + map_height_px: total map height in pixels. + """ + # Desired camera top-left so target is centred in the viewport + x = target_center.x - NATIVE_W / 2 + y = target_center.y - NATIVE_H / 2 + + # Clamp so the camera never goes outside the map + x = max(0.0, min(x, float(map_width_px - NATIVE_W))) + y = max(0.0, min(y, float(map_height_px - NATIVE_H))) + + self.offset.x = x + self.offset.y = y diff --git a/src/systems/encounter.py b/src/systems/encounter.py new file mode 100644 index 0000000..5f0dd02 --- /dev/null +++ b/src/systems/encounter.py @@ -0,0 +1,71 @@ +""" +EncounterSystem: tracks player steps and randomly triggers battles. + +Usage +----- + enc = EncounterSystem() + enc.load_encounters("data/encounters.json") + # each tile step: + enemy_ids = enc.on_step("grasslands") + if enemy_ids: + # start battle with those enemy IDs + ... +""" +from __future__ import annotations + +import json +import random + + +class EncounterSystem: + """Manages random encounter thresholds and encounter tables.""" + + def __init__(self, min_steps: int = 80, max_steps: int = 120): + self._min = min_steps + self._max = max_steps + + self.step_count: float = 0.0 + self.step_threshold: int = random.randint(self._min, self._max) + + self._tables: dict[str, list[list[str]]] = {} + + # ------------------------------------------------------------------ + + def load_encounters(self, path: str) -> None: + with open(path, "r", encoding="utf-8") as fh: + self._tables = json.load(fh) + + # ------------------------------------------------------------------ + + def on_step(self, zone: str, delta: float = 1.0) -> list[str] | None: + """ + Accumulate *delta* steps for *zone*. + Returns a list of enemy IDs if an encounter triggers, else None. + """ + self.step_count += delta + if self.step_count >= self.step_threshold: + self.reset() + return self._pick_encounter(zone) + return None + + def reset(self) -> None: + """Reset the step counter and pick a new random threshold.""" + self.step_count = 0.0 + self.step_threshold = random.randint(self._min, self._max) + + # ------------------------------------------------------------------ + + def _pick_encounter(self, zone: str) -> list[str]: + table = self._tables.get(zone, []) + if not table: + # Fallback: single default enemy per zone + fallbacks = { + "grasslands": ["dire_wolf"], + "forest": ["shadow_panther"], + "mountains": ["rock_golem"], + "dark_lands": ["black_knight_soldier"], + "subterra_passage": ["cave_crawler"], + } + return fallbacks.get(zone, ["dire_wolf"]) + # Return a shallow copy so callers cannot mutate the encounter table + return list(random.choice(table)) diff --git a/src/systems/inventory.py b/src/systems/inventory.py new file mode 100644 index 0000000..6fa8f28 --- /dev/null +++ b/src/systems/inventory.py @@ -0,0 +1,234 @@ +"""Inventory system: item loading, management, usage, and equipment.""" +from __future__ import annotations + +import json +import random + +from src.systems.battle_engine import BattleResult + +_MAX_STACK = 99 + + +class InventorySystem: + def __init__(self): + self.items: dict = {} # item_id -> item_data + + def load_items(self, path: str) -> None: + """Load items from a JSON file into self.items dict.""" + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + self.items = {item["id"]: item for item in data["items"]} + + def add_item(self, inventory_list: list, item_id: str, qty: int = 1) -> None: + """Add qty of item_id to inventory_list (max _MAX_STACK per stack).""" + for entry in inventory_list: + if entry["item_id"] == item_id: + entry["qty"] = min(_MAX_STACK, entry["qty"] + qty) + return + inventory_list.append({"item_id": item_id, "qty": min(_MAX_STACK, qty)}) + + def remove_item(self, inventory_list: list, item_id: str, qty: int = 1) -> bool: + """Remove qty of item_id from inventory_list. Returns False if insufficient.""" + for entry in inventory_list: + if entry["item_id"] == item_id: + if entry["qty"] < qty: + return False + entry["qty"] -= qty + if entry["qty"] == 0: + inventory_list.remove(entry) + return True + return False + + def use_item(self, item_id: str, user, target, inventory_list: list, battle_state=None) -> str: + """ + Use an item from inventory_list. + - consumable: apply HP/MP heal, cure status, revive, etc. + - battle_item: deal damage to target(s), or special effects + Returns a human-readable message string. + """ + if item_id not in self.items: + return f"Unknown item: {item_id}" + + item = self.items[item_id] + + if not self.remove_item(inventory_list, item_id, 1): + return f"You don't have {item['name']}!" + + itype = item.get("type", "consumable") + effect = item.get("effect") or "" + + if itype == "consumable": + if effect == "heal_hp": + power = item.get("power") or 100 + healed = min(power, target.max_hp - target.hp) + target.hp = min(target.max_hp, target.hp + power) + return f"Used {item['name']}! {target.name} recovers {healed} HP." + + elif effect == "heal_mp": + power = item.get("power") or 30 + restored = min(power, target.max_mp - target.mp) + target.mp = min(target.max_mp, target.mp + power) + return f"Used {item['name']}! {target.name} recovers {restored} MP." + + elif effect == "heal_full": + target.hp = target.max_hp + target.mp = target.max_mp + return f"Used {item['name']}! {target.name} fully restored!" + + elif effect == "revive": + if target.hp <= 0: + power = item.get("power") or 25 + restore = max(1, target.max_hp * power // 100) + target.hp = restore + return f"Used {item['name']}! {target.name} revived with {restore} HP!" + return f"{target.name} is already alive!" + + elif effect == "heal_hp_mp_half": + target.hp = min(target.max_hp, target.hp + target.max_hp // 2) + target.mp = min(target.max_mp, target.mp + target.max_mp // 2) + return f"Used {item['name']}! Restored 50% HP and MP." + + elif effect.startswith("cure_"): + status = effect[5:] + if status in getattr(target, "status_effects", {}): + del target.status_effects[status] + return f"Used {item['name']}! {target.name} cured of {status}." + return f"{target.name} is not affected by {status}." + + elif itype == "battle_item": + if effect == "flee": + if battle_state is not None: + battle_state.phase = "fled" + return f"Used {item['name']}! Escaped safely!" + + elif effect == "sleep_all": + t_list = ([t for t in battle_state.enemies if t.is_alive] + if battle_state else ([target] if target else [])) + msgs = [] + for t in t_list: + if random.random() < 0.8: + t.apply_status("sleep", 3) + msgs.append(f"{t.name} fell asleep!") + else: + msgs.append(f"{t.name} resisted!") + return f"Used {item['name']}! " + " ".join(msgs) + + elif effect == "damage": + power = item.get("power") or 200 + item_target = item.get("target", "single_enemy") + element = item.get("element") + + if item_target == "all_enemies" and battle_state: + all_targets = [t for t in battle_state.enemies if t.is_alive] + else: + all_targets = [target] if target else [] + + msgs = [] + for t in all_targets: + dmg = int(power * random.uniform(0.9, 1.1)) + if element: + if element in getattr(t, "immunities", []): + msgs.append(f"{t.name} is immune!") + continue + if element in getattr(t, "weaknesses", []): + dmg = int(dmg * 2.0) + elif element in getattr(t, "resistances", []): + dmg = int(dmg * 0.5) + actual = t.take_damage(dmg) + msgs.append(f"{t.name} takes {actual} dmg!") + return f"Used {item['name']}! " + " ".join(msgs) + + return f"Used {item['name']}." + + def equip_item(self, player, item_id: str) -> str: + """Equip an item to its slot, unequipping any previous item.""" + if item_id not in self.items: + return f"Unknown item: {item_id}" + + item = self.items[item_id] + if item.get("type") != "equipment": + return f"{item['name']} is not equipment!" + + slot = item.get("slot") + if not slot: + return "This item has no equipment slot." + + # Unequip existing item → return to inventory + old_id = getattr(player, slot, None) + if old_id: + self.add_item(player.inventory, old_id, 1) + + # Remove new item from inventory + if not self.remove_item(player.inventory, item_id, 1): + return f"You don't have {item['name']} in your inventory!" + + setattr(player, slot, item_id) + self.recalculate_stats(player) + return f"Equipped {item['name']}." + + def unequip_item(self, player, slot: str) -> str: + """Unequip item from the given slot, returning it to inventory.""" + item_id = getattr(player, slot, None) + if not item_id: + return f"Nothing equipped in {slot}." + + self.add_item(player.inventory, item_id, 1) + setattr(player, slot, None) + self.recalculate_stats(player) + item = self.items.get(item_id, {}) + return f"Unequipped {item.get('name', item_id)}." + + def recalculate_stats(self, player) -> None: + """ + Reset player's effective stats to base values, then apply all equipment bonuses. + Base stats stored as _base_str, _base_def, etc. + Weapon bonus → player._equip_atk (used by get_attack_power) + Shield/helmet/armor def bonus → player._equip_def (used by get_armor_defense) + Armor mdf bonus and accessory bonuses → directly added to effective stats. + """ + # Reset to base stats (fall back to current values if base not stored yet) + player.str_ = getattr(player, "_base_str", player.str_) + player.def_ = getattr(player, "_base_def", player.def_) + player.mag = getattr(player, "_base_mag", player.mag) + player.mdf = getattr(player, "_base_mdf", player.mdf) + player.spd = getattr(player, "_base_spd", player.spd) + player.lck = getattr(player, "_base_lck", player.lck) + + player._equip_atk = 0 + player._equip_def = 0 + + for slot in ("weapon", "shield", "helmet", "armor", "accessory"): + item_id = getattr(player, slot, None) + if not item_id or item_id not in self.items: + continue + stats = self.items[item_id].get("stats", {}) or {} + if slot == "weapon": + player._equip_atk += stats.get("atk", 0) + elif slot in ("shield", "helmet", "armor"): + player._equip_def += stats.get("def", 0) + player.mdf += stats.get("mdf", 0) + elif slot == "accessory": + player.str_ += stats.get("str", 0) + player.def_ += stats.get("def", 0) + player.mag += stats.get("mag", 0) + player.mdf += stats.get("mdf", 0) + player.spd += stats.get("spd", 0) + player.lck += stats.get("lck", 0) + + def get_usable_items(self, inventory_list: list, in_battle: bool) -> list[dict]: + """Return list of inventory entries usable in the current context.""" + result = [] + for entry in inventory_list: + item_id = entry["item_id"] + if item_id not in self.items: + continue + item = self.items[item_id] + itype = item.get("type", "consumable") + if itype not in ("consumable", "battle_item"): + continue + if in_battle: + if item.get("usable_in_battle", False): + result.append(entry) + else: + result.append(entry) + return result diff --git a/src/systems/magic.py b/src/systems/magic.py new file mode 100644 index 0000000..fdcf862 --- /dev/null +++ b/src/systems/magic.py @@ -0,0 +1,137 @@ +"""Magic system: spell loading, casting, and effect resolution.""" +from __future__ import annotations + +import json +from src.systems.battle_engine import BattleResult + + +class MagicSystem: + def __init__(self): + self.spells: dict = {} # spell_id -> spell_data + + def load_spells(self, path: str) -> None: + """Load spells from a JSON file into self.spells dict.""" + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + self.spells = {s["id"]: s for s in data["spells"]} + + def cast_spell(self, caster, spell_id: str, targets: list, battle_state, battle_engine) -> list: + """ + Cast a spell. Returns list of BattleResult objects. + Checks: spell known, MP cost, silence status. + Deducts MP and applies effect based on type. + """ + if spell_id not in self.spells: + return [BattleResult(message=f"Unknown spell: {spell_id}")] + + spell = self.spells[spell_id] + + # Check caster knows the spell + if spell_id not in getattr(caster, "spells", []): + return [BattleResult(message=f"{caster.name} doesn't know {spell['name']}!")] + + # Silence check (before MP deduction) + if getattr(caster, "status_effects", {}).get("silence", 0) > 0: + return [BattleResult(message=f"{caster.name} is silenced and cannot cast!")] + + # MP check + mp_cost = spell["mp_cost"] + if caster.mp < mp_cost: + return [BattleResult(message=f"Not enough MP! Need {mp_cost}, have {caster.mp}.")] + + # Deduct MP + caster.mp -= mp_cost + + spell_type = spell["type"] + spell_name = spell["name"] + results = [] + + if spell_type == "magic_damage": + for target in targets: + result = battle_engine.magic_damage( + caster, target, + spell_name, + spell.get("element"), + spell.get("power", 12), + battle_state, + ) + results.append(result) + + elif spell_type == "heal": + for target in targets: + power = spell.get("power", 100) + healed = min(power, target.max_hp - target.hp) + target.hp = min(target.max_hp, target.hp + power) + results.append(BattleResult( + damage=-healed, + message=f"{caster.name} casts {spell_name}! {target.name} recovers {healed} HP.", + )) + + elif spell_type == "buff": + buff_name = spell.get("buff", spell.get("effect", "")) + duration = spell.get("duration", 3) + for target in targets: + target.apply_status(buff_name, duration) + results.append(BattleResult( + message=f"{caster.name} casts {spell_name}! {target.name} gains {buff_name}.", + )) + + elif spell_type == "debuff": + debuff_name = spell.get("debuff", spell.get("effect", "")) + duration = spell.get("duration", 3) + for target in targets: + applied = target.apply_status(debuff_name, duration) + if applied: + results.append(BattleResult( + status_applied=debuff_name, + message=f"{caster.name} casts {spell_name}! {target.name} is afflicted with {debuff_name}.", + )) + else: + results.append(BattleResult( + message=f"{target.name} resisted {spell_name}!", + )) + + elif spell_type == "scan": + for target in targets: + msg = ( + f"[Scan] {target.name}: HP {target.hp}/{target.max_hp} " + f"STR {target.str_} DEF {target.def_} " + f"MAG {target.mag} MDF {target.mdf} SPD {target.spd}" + ) + weaknesses = getattr(target, "weaknesses", []) + if weaknesses: + msg += f" Weak: {','.join(weaknesses)}" + results.append(BattleResult(message=msg)) + + elif spell_type == "revive": + for target in targets: + if target.hp <= 0: + power = spell.get("power", 25) + restore = max(1, int(target.max_hp * power // 100)) + target.hp = restore + results.append(BattleResult( + message=f"{caster.name} casts {spell_name}! {target.name} revived with {restore} HP!", + )) + else: + results.append(BattleResult( + message=f"{target.name} is already alive!", + )) + + elif spell_type == "dispel": + for target in targets: + target.status_effects.clear() + results.append(BattleResult( + message=f"{caster.name} casts {spell_name}! All status effects cleared from {target.name}.", + )) + + else: + results.append(BattleResult(message=f"{caster.name} casts {spell_name}!")) + + return results + + def get_learnable_spells(self, level: int) -> list[str]: + """Return list of spell IDs learnable at or below the given level.""" + return [ + s["id"] for s in self.spells.values() + if s.get("learn_level", 99) <= level + ] diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/battle_hud.py b/src/ui/battle_hud.py new file mode 100644 index 0000000..54f2344 --- /dev/null +++ b/src/ui/battle_hud.py @@ -0,0 +1,154 @@ +"""Battle HUD: draws HP/MP bars, command menu, enemy info, message log.""" +from __future__ import annotations + +import pygame + + +class BattleHUD: + """ + Pure drawing helper; holds no game state. + All methods accept a pygame.Surface and draw on it directly. + """ + + # Colour constants + _BAR_BG = (40, 40, 40) + _HP_HIGH = (60, 200, 60) + _HP_MID = (220, 200, 30) + _HP_LOW = (220, 50, 50) + _MP_COLOR = (60, 100, 220) + _BORDER = (180, 180, 180) + _MENU_BG = (20, 20, 40) + _MENU_SEL_BG = (60, 60, 120) + _TEXT_COLOR = (240, 240, 240) + _LOG_BG = (10, 10, 20) + + def __init__(self): + self._font_sm = pygame.font.SysFont("monospace", 7) + self._font_med = pygame.font.SysFont("monospace", 8) + + # ------------------------------------------------------------------ + # HP bar + # ------------------------------------------------------------------ + + def draw_hp_bar( + self, + surface: pygame.Surface, + current: int, + maximum: int, + x: int, + y: int, + width: int = 60, + height: int = 5, + ) -> None: + ratio = max(0.0, current / max(1, maximum)) + if ratio > 0.5: + color = self._HP_HIGH + elif ratio > 0.25: + color = self._HP_MID + else: + color = self._HP_LOW + + pygame.draw.rect(surface, self._BAR_BG, (x, y, width, height)) + fill_w = int(width * ratio) + if fill_w > 0: + pygame.draw.rect(surface, color, (x, y, fill_w, height)) + pygame.draw.rect(surface, self._BORDER, (x, y, width, height), 1) + + # ------------------------------------------------------------------ + # Player info + # ------------------------------------------------------------------ + + def draw_player_info( + self, + surface: pygame.Surface, + player, + x: int, + y: int, + ) -> None: + # Name + level + name_surf = self._font_med.render( + f"LV{player.level} {getattr(player, 'name', 'Hero')}", True, self._TEXT_COLOR + ) + surface.blit(name_surf, (x, y)) + + # HP row + hp_label = self._font_sm.render( + f"HP {player.hp:>3}/{player.max_hp:<3}", True, self._TEXT_COLOR + ) + surface.blit(hp_label, (x, y + 10)) + self.draw_hp_bar(surface, player.hp, player.max_hp, x + 60, y + 11, 60, 5) + + # MP row + mp_label = self._font_sm.render( + f"MP {player.mp:>3}/{player.max_mp:<3}", True, self._MP_COLOR + ) + surface.blit(mp_label, (x, y + 18)) + pygame.draw.rect(surface, self._BAR_BG, (x + 60, y + 19, 60, 5)) + mp_fill = int(60 * player.mp / max(1, player.max_mp)) + if mp_fill > 0: + pygame.draw.rect(surface, self._MP_COLOR, (x + 60, y + 19, mp_fill, 5)) + pygame.draw.rect(surface, self._BORDER, (x + 60, y + 19, 60, 5), 1) + + # ------------------------------------------------------------------ + # Enemy info + # ------------------------------------------------------------------ + + def draw_enemy_info( + self, + surface: pygame.Surface, + enemy, + x: int, + y: int, + ) -> None: + name_surf = self._font_sm.render(enemy.name, True, self._TEXT_COLOR) + surface.blit(name_surf, (x, y)) + self.draw_hp_bar(surface, enemy.hp, enemy.max_hp, x, y + 9, 48, 4) + + # ------------------------------------------------------------------ + # Command menu + # ------------------------------------------------------------------ + + def draw_command_menu( + self, + surface: pygame.Surface, + commands: list[str], + selected_idx: int, + x: int, + y: int, + ) -> None: + item_h = 10 + width = 56 + total_h = len(commands) * item_h + 4 + + pygame.draw.rect(surface, self._MENU_BG, (x - 2, y - 2, width, total_h)) + pygame.draw.rect(surface, self._BORDER, (x - 2, y - 2, width, total_h), 1) + + for i, cmd in enumerate(commands): + iy = y + i * item_h + if i == selected_idx: + pygame.draw.rect(surface, self._MENU_SEL_BG, (x - 2, iy, width, item_h)) + prefix = ">" + else: + prefix = " " + label = self._font_sm.render(f"{prefix}{cmd}", True, self._TEXT_COLOR) + surface.blit(label, (x, iy)) + + # ------------------------------------------------------------------ + # Message log + # ------------------------------------------------------------------ + + def draw_message_log( + self, + surface: pygame.Surface, + messages: list[str], + x: int, + y: int, + width: int = 200, + ) -> None: + visible = messages[-2:] if len(messages) >= 2 else messages + h = len(visible) * 10 + 4 + pygame.draw.rect(surface, self._LOG_BG, (x - 2, y - 2, width, h)) + + for i, msg in enumerate(visible): + surf = self._font_sm.render(msg[:36], True, self._TEXT_COLOR) + surface.blit(surf, (x, y + i * 10)) diff --git a/src/ui/hud.py b/src/ui/hud.py new file mode 100644 index 0000000..19b8d25 --- /dev/null +++ b/src/ui/hud.py @@ -0,0 +1,70 @@ +"""Overworld HUD: HP/MP bars, area name, level and gold.""" +from __future__ import annotations + +import pygame +from settings import WHITE, GREEN, YELLOW, NATIVE_W, NATIVE_H + + +class HUD: + """Minimal overworld HUD drawn on the native surface.""" + + _BAR_BG = (40, 40, 40) + _HP_HIGH = (60, 200, 60) + _HP_MID = (220, 200, 30) + _HP_LOW = (220, 50, 50) + _MP_COLOR = (60, 100, 220) + _BORDER = (180, 180, 180) + _OVERLAY = (0, 0, 0, 140) + + def __init__(self): + self._font_sm = pygame.font.SysFont("monospace", 7) + self._font_med = pygame.font.SysFont("monospace", 8, bold=True) + self._area_name: str = "" + + def set_area_name(self, name: str) -> None: + self._area_name = name + + def draw(self, surface: pygame.Surface, player) -> None: + """Draw the minimal overworld HUD.""" + self._draw_hp_mp(surface, player) + self._draw_area_name(surface) + self._draw_level_gold(surface, player) + + def _draw_hp_mp(self, surface: pygame.Surface, player) -> None: + x, y = 4, NATIVE_H - 22 + bar_w = 50 + + # HP label + bar + hp_r = max(0.0, player.hp / max(1, player.max_hp)) + hp_color = (self._HP_HIGH if hp_r > 0.5 else + self._HP_MID if hp_r > 0.25 else self._HP_LOW) + hp_lbl = self._font_sm.render(f"HP{player.hp:>3}", True, WHITE) + surface.blit(hp_lbl, (x, y)) + pygame.draw.rect(surface, self._BAR_BG, (x + 28, y + 1, bar_w, 5)) + fw = int(bar_w * hp_r) + if fw > 0: + pygame.draw.rect(surface, hp_color, (x + 28, y + 1, fw, 5)) + pygame.draw.rect(surface, self._BORDER, (x + 28, y + 1, bar_w, 5), 1) + + # MP label + bar + mp_r = max(0.0, player.mp / max(1, player.max_mp)) + mp_lbl = self._font_sm.render(f"MP{player.mp:>3}", True, self._MP_COLOR) + surface.blit(mp_lbl, (x, y + 10)) + pygame.draw.rect(surface, self._BAR_BG, (x + 28, y + 11, bar_w, 5)) + fw2 = int(bar_w * mp_r) + if fw2 > 0: + pygame.draw.rect(surface, self._MP_COLOR, (x + 28, y + 11, fw2, 5)) + pygame.draw.rect(surface, self._BORDER, (x + 28, y + 11, bar_w, 5), 1) + + def _draw_area_name(self, surface: pygame.Surface) -> None: + if not self._area_name: + return + s = self._font_med.render(self._area_name, True, WHITE) + r = s.get_rect(centerx=NATIVE_W // 2, y=4) + surface.blit(s, r) + + def _draw_level_gold(self, surface: pygame.Surface, player) -> None: + txt = f"LV{player.level} G{player.gold}" + s = self._font_sm.render(txt, True, YELLOW) + r = s.get_rect(right=NATIVE_W - 4, bottom=NATIVE_H - 4) + surface.blit(s, r) diff --git a/src/ui/menu.py b/src/ui/menu.py new file mode 100644 index 0000000..19542cd --- /dev/null +++ b/src/ui/menu.py @@ -0,0 +1,96 @@ +"""Reusable scrollable menu widget.""" +from __future__ import annotations + +import pygame +from settings import WHITE, YELLOW, BLACK + + +class Menu: + """ + A simple bordered menu box with a cursor. + + Parameters + ---------- + options : list of strings to display + x, y : top-left position on the native surface + width : pixel width of the menu box + item_height : pixel height of each row + font_size : size for pygame.font.SysFont("monospace", ...) + """ + + _BG = (20, 20, 40) + _BORDER = (180, 180, 200) + _SEL_BG = (60, 60, 120) + _TEXT = (240, 240, 240) + _CURSOR = (255, 230, 50) # YELLOW + + def __init__( + self, + options: list[str], + x: int, + y: int, + width: int, + item_height: int = 10, + font_size: int = 7, + ): + self.options = options + self.x = x + self.y = y + self.width = width + self.item_height = item_height + self._idx = 0 + self._font = pygame.font.SysFont("monospace", font_size) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def selected_index(self) -> int: + return self._idx + + @selected_index.setter + def selected_index(self, value: int): + if self.options: + self._idx = value % len(self.options) + + # ------------------------------------------------------------------ + # Input + # ------------------------------------------------------------------ + + def handle_input(self, event: pygame.event.Event): + """ + Process a KEYDOWN event. + Returns the selected option string on RETURN/z, None otherwise. + """ + if event.type != pygame.KEYDOWN or not self.options: + return None + if event.key in (pygame.K_UP, pygame.K_w): + self._idx = (self._idx - 1) % len(self.options) + elif event.key in (pygame.K_DOWN, pygame.K_s): + self._idx = (self._idx + 1) % len(self.options) + elif event.key in (pygame.K_RETURN, pygame.K_z): + return self.options[self._idx] + return None + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface) -> None: + """Draw the bordered menu box with all options and cursor.""" + total_h = len(self.options) * self.item_height + 4 + pygame.draw.rect(surface, self._BG, (self.x - 2, self.y - 2, self.width, total_h)) + pygame.draw.rect(surface, self._BORDER, (self.x - 2, self.y - 2, self.width, total_h), 1) + + for i, option in enumerate(self.options): + iy = self.y + i * self.item_height + if i == self._idx: + pygame.draw.rect(surface, self._SEL_BG, (self.x - 2, iy, self.width, self.item_height)) + prefix = ">" + color = self._CURSOR + else: + prefix = " " + color = self._TEXT + label = self._font.render(f"{prefix}{option}", True, color) + surface.blit(label, (self.x, iy)) diff --git a/src/ui/text_box.py b/src/ui/text_box.py new file mode 100644 index 0000000..791b9dd --- /dev/null +++ b/src/ui/text_box.py @@ -0,0 +1,124 @@ +"""TextBox widget with typewriter reveal effect.""" +from __future__ import annotations + +import pygame +from settings import WHITE + + +class TextBox: + """ + A bordered text box that reveals text character by character. + + Parameters + ---------- + text : full string to display + x, y : top-left position + width, height : dimensions of the box + font_size : monospace font size + typewriter_speed : characters per second to reveal + """ + + _BG = (10, 10, 30) + _BORDER = (180, 180, 200) + _TEXT = (240, 240, 240) + + def __init__( + self, + text: str, + x: int, + y: int, + width: int, + height: int, + font_size: int = 7, + typewriter_speed: float = 30.0, + ): + self.x = x + self.y = y + self.width = width + self.height = height + self._speed = typewriter_speed + self._font = pygame.font.SysFont("monospace", font_size) + + self._full_text = text + self._visible = 0.0 # fractional number of chars revealed + self._done = False # True when advance() signals completion + + self._lines: list[str] = self._wrap(text, width - 8) + + # ------------------------------------------------------------------ + # Word wrap + # ------------------------------------------------------------------ + + def _wrap(self, text: str, max_px: int) -> list[str]: + """Break text into lines that fit within max_px pixels.""" + lines: list[str] = [] + for paragraph in text.split("\n"): + words = paragraph.split(" ") + current = "" + for word in words: + test = (current + " " + word).strip() + if self._font.size(test)[0] <= max_px: + current = test + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines if lines else [""] + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def is_complete(self) -> bool: + """True when all text has been revealed.""" + return int(self._visible) >= len(self._full_text) + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + + def update(self, dt: float) -> None: + """Advance the typewriter by dt seconds.""" + if not self.is_complete: + self._visible = min(len(self._full_text), self._visible + self._speed * dt) + + # ------------------------------------------------------------------ + # Advance + # ------------------------------------------------------------------ + + def advance(self) -> bool: + """ + If not complete: skip to full text. + If complete: return True (signals caller to close/continue). + Returns True when the box should be dismissed. + """ + if self.is_complete: + self._done = True + return True + self._visible = float(len(self._full_text)) + return False + + # ------------------------------------------------------------------ + # Draw + # ------------------------------------------------------------------ + + def draw(self, surface: pygame.Surface) -> None: + """Draw the bordered box with the partially-revealed text.""" + pygame.draw.rect(surface, self._BG, (self.x, self.y, self.width, self.height)) + pygame.draw.rect(surface, self._BORDER, (self.x, self.y, self.width, self.height), 1) + + # Build the visible substring + visible_str = self._full_text[: int(self._visible)] + # Re-wrap visible portion + vis_lines = self._wrap(visible_str, self.width - 8) + + line_h = self._font.get_linesize() + for i, line in enumerate(vis_lines): + ty = self.y + 4 + i * line_h + if ty + line_h > self.y + self.height: + break + surf = self._font.render(line, True, self._TEXT) + surface.blit(surf, (self.x + 4, ty)) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/animation.py b/src/utils/animation.py new file mode 100644 index 0000000..23f542e --- /dev/null +++ b/src/utils/animation.py @@ -0,0 +1,61 @@ +"""Utility: frame-based animation controller.""" + + +class AnimationController: + """ + Manages named animations, each being a list of pygame.Surface frames. + + Usage:: + + anim = AnimationController() + anim.add("walk_down", frames_list, fps=8) + anim.play("walk_down") + current_frame = anim.get_frame() + anim.update(dt) + """ + + def __init__(self): + # name → {"frames": [...], "fps": float} + self._animations: dict = {} + self._current: str | None = None + self._frame_index: int = 0 + self._timer: float = 0.0 + + # ------------------------------------------------------------------ + def add(self, name: str, frames: list, fps: float = 8.0): + """Register an animation.""" + self._animations[name] = {"frames": frames, "fps": fps} + + def play(self, name: str): + """Switch to animation *name*. Resets frame counter if changed.""" + if name not in self._animations: + return + if self._current != name: + self._current = name + self._frame_index = 0 + self._timer = 0.0 + + def update(self, dt: float): + """Advance animation timer and flip frame when due.""" + if self._current is None: + return + anim = self._animations[self._current] + fps = anim["fps"] + if fps <= 0: + return + self._timer += dt + frame_duration = 1.0 / fps + while self._timer >= frame_duration: + self._timer -= frame_duration + self._frame_index = (self._frame_index + 1) % len(anim["frames"]) + + def get_frame(self): + """Return the current frame surface, or None if no animation set.""" + if self._current is None: + return None + anim = self._animations[self._current] + return anim["frames"][self._frame_index] + + @property + def current(self) -> str | None: + return self._current diff --git a/src/utils/spritesheet.py b/src/utils/spritesheet.py new file mode 100644 index 0000000..7b0216a --- /dev/null +++ b/src/utils/spritesheet.py @@ -0,0 +1,61 @@ +"""Utility: loads frames from a spritesheet image.""" +import os +import pygame +from settings import TILE_SIZE + + +class SpritesheetLoader: + """ + Slices a spritesheet PNG into individual frame surfaces. + + Usage:: + + loader = SpritesheetLoader() + frames = loader.load("assets/sprites/hero.png", 16, 16) + """ + + def load( + self, + path: str, + frame_width: int = TILE_SIZE, + frame_height: int = TILE_SIZE, + colorkey=None, + ) -> list: + """ + Return a list of pygame.Surface frames sliced from the spritesheet. + If the file is missing or unreadable, returns a single placeholder + coloured surface so the caller never crashes. + """ + if not os.path.exists(path): + return [self._placeholder(frame_width, frame_height)] + + try: + sheet = pygame.image.load(path).convert_alpha() + except pygame.error: + return [self._placeholder(frame_width, frame_height)] + + sheet_w, sheet_h = sheet.get_size() + frames: list[pygame.Surface] = [] + + for row in range(sheet_h // frame_height): + for col in range(sheet_w // frame_width): + rect = pygame.Rect( + col * frame_width, + row * frame_height, + frame_width, + frame_height, + ) + frame = pygame.Surface((frame_width, frame_height), pygame.SRCALPHA) + frame.blit(sheet, (0, 0), rect) + if colorkey is not None: + frame.set_colorkey(colorkey) + frames.append(frame) + + return frames if frames else [self._placeholder(frame_width, frame_height)] + + # ------------------------------------------------------------------ + @staticmethod + def _placeholder(w: int, h: int) -> pygame.Surface: + surf = pygame.Surface((w, h)) + surf.fill((180, 0, 180)) # magenta = obviously missing + return surf diff --git a/src/utils/tilemap.py b/src/utils/tilemap.py new file mode 100644 index 0000000..dce6ec2 --- /dev/null +++ b/src/utils/tilemap.py @@ -0,0 +1,149 @@ +"""Utility: loads a plain-text tile map and builds sprite groups from it.""" +import os +import pygame +from settings import TILE_SIZE, TILE_WALL_COLOR, TILE_FLOOR_COLOR, TILE_GRASS_COLOR, TILE_TOWN_COLOR, TILE_NPC_COLOR + + +# Tile legend +TILE_CHARS = { + "W": "wall", + ".": "floor", + "P": "floor", # player spawn (treated as floor) + "G": "grass", # encounter zone + "T": "town", # town entrance + "N": "floor", # NPC position (floor underneath) +} + +TILE_COLORS = { + "wall": TILE_WALL_COLOR, + "floor": TILE_FLOOR_COLOR, + "grass": TILE_GRASS_COLOR, + "town": TILE_TOWN_COLOR, + "npc": TILE_NPC_COLOR, +} + + +class Tile(pygame.sprite.Sprite): + """A single map tile sprite.""" + + def __init__(self, tile_type: str, col: int, row: int): + super().__init__() + self.tile_type = tile_type + self.col = col + self.row = row + self.image = pygame.Surface((TILE_SIZE, TILE_SIZE)) + self.image.fill(TILE_COLORS.get(tile_type, (0, 0, 0))) + # Draw a subtle 1-px border so tiles are distinguishable + pygame.draw.rect(self.image, (0, 0, 0), self.image.get_rect(), 1) + self.rect = self.image.get_rect( + topleft=(col * TILE_SIZE, row * TILE_SIZE) + ) + + +class TileMap: + """ + Parses a plain-text map file and exposes sprite groups plus metadata. + + Tile characters: + W – wall (solid) + . – floor (walkable) + P – player spawn (walkable) + G – grass / encounter zone (walkable) + T – town entrance (walkable, triggers state change) + N – NPC spawn marker (walkable, NPC placed on top) + """ + + def __init__(self, map_path: str): + self.tiles: list[list[Tile]] = [] + self.cols = 0 + self.rows = 0 + self.player_spawn: tuple[int, int] = (1, 1) # (col, row) fallback + + # Sprite groups + self.all_tiles = pygame.sprite.Group() + self.wall_tiles = pygame.sprite.Group() + self.floor_tiles = pygame.sprite.Group() + self.encounter_tiles = pygame.sprite.Group() + self.town_entrances = pygame.sprite.Group() + self.npc_spawns = pygame.sprite.Group() + + self._load(map_path) + + # ------------------------------------------------------------------ + def _load(self, path: str): + if not os.path.exists(path): + # Generate a tiny fallback map so the game still boots + self._build_fallback() + return + + with open(path, "r") as fh: + lines = [line.rstrip("\n") for line in fh.readlines()] + + self.rows = len(lines) + self.cols = max(len(line) for line in lines) if lines else 1 + + for row, line in enumerate(lines): + row_tiles: list[Tile] = [] + for col, ch in enumerate(line): + tile_type = TILE_CHARS.get(ch, "floor") + tile = Tile(tile_type, col, row) + row_tiles.append(tile) + self.all_tiles.add(tile) + + if tile_type == "wall": + self.wall_tiles.add(tile) + elif tile_type in ("floor",): + self.floor_tiles.add(tile) + elif tile_type == "grass": + self.encounter_tiles.add(tile) + self.floor_tiles.add(tile) + elif tile_type == "town": + self.town_entrances.add(tile) + self.floor_tiles.add(tile) + + if ch == "P": + self.player_spawn = (col, row) + elif ch == "N": + self.npc_spawns.add(tile) + + self.tiles.append(row_tiles) + + def _build_fallback(self): + """3×3 minimal map so the game never crashes on missing file.""" + self.cols = 3 + self.rows = 3 + for row in range(3): + row_tiles = [] + for col in range(3): + ch = "W" if (row == 0 or col == 0 or row == 2 or col == 2) else "." + tile = Tile(TILE_CHARS.get(ch, "floor"), col, row) + row_tiles.append(tile) + self.all_tiles.add(tile) + if ch == "W": + self.wall_tiles.add(tile) + else: + self.floor_tiles.add(tile) + self.tiles.append(row_tiles) + self.player_spawn = (1, 1) + + # ------------------------------------------------------------------ + @property + def pixel_width(self) -> int: + return self.cols * TILE_SIZE + + @property + def pixel_height(self) -> int: + return self.rows * TILE_SIZE + + def get_tile_at(self, col: int, row: int): + """Return the Tile at grid position, or None if out of bounds.""" + if 0 <= row < len(self.tiles) and 0 <= col < len(self.tiles[row]): + return self.tiles[row][col] + return None + + def draw(self, surface: pygame.Surface, camera_offset: pygame.Vector2): + """Draw all tiles shifted by camera_offset.""" + ox, oy = int(camera_offset.x), int(camera_offset.y) + for tile in self.all_tiles: + dest = (tile.rect.x - ox, tile.rect.y - oy) + surface.blit(tile.image, dest)