diff --git a/data/monsters/monsters.json b/data/monsters/monsters.json index 667da6a..9262c53 100644 --- a/data/monsters/monsters.json +++ b/data/monsters/monsters.json @@ -5,6 +5,7 @@ "radius": 16.0, "health": 30.0, "speed": 1.0, + "xp_reward": 40, "attack": { "cooldownDuration": 1500.0, "range": 40.0, @@ -18,6 +19,7 @@ "radius": 16.0, "health": 30.0, "speed": 1.0, + "xp_reward": 40, "attack": { "cooldownDuration": 1500.0, "range": 40.0, @@ -31,6 +33,7 @@ "radius": 16.0, "health": 40.0, "speed": 0.7, + "xp_reward": 60, "attack": { "cooldownDuration": 2000.0, "range": 35.0, diff --git a/src/saland/Game.cpp b/src/saland/Game.cpp index 6eecb4c..377a000 100644 --- a/src/saland/Game.cpp +++ b/src/saland/Game.cpp @@ -39,6 +39,7 @@ https://github.com/sago007/saland #include "model/placeables.hpp" #include "model/spells.hpp" #include "model/Player.hpp" +#include "model/LevelSystem.hpp" #include "../os.hpp" #include "SDL.h" #include @@ -345,6 +346,16 @@ Game::Game() { data->human->race = globalData.player.get_visible_race(); data->human->top = globalData.player.get_visible_top(); + // Apply level stats from saved player data + data->human->xp = globalData.player.xp; + data->human->level = globalData.player.level; + LevelStats playerStats = calculateStats(100.0f, 50.0f, 1.0f, data->human->level); + data->human->maxHealth = playerStats.maxHealth; + data->human->health = playerStats.maxHealth; + data->human->maxMana = playerStats.maxMana; + data->human->mana = playerStats.maxMana; + data->human->damageMultiplier = playerStats.damageMultiplier; + data->bottomField.SetHolder(globalData.dataHolder); data->bottomField.SetFontSize(20); data->middleField.SetHolder(globalData.dataHolder); @@ -574,7 +585,7 @@ void Game::Draw(SDL_Renderer* target) { static char healthText[32]; static sago::SagoTextField healthField; healthField.SetHolder(globalData.dataHolder); - snprintf(healthText, sizeof(healthText), "Health: %d/100", static_cast(data->human->health)); + snprintf(healthText, sizeof(healthText), "Health: %d/%d", static_cast(data->human->health), static_cast(data->human->maxHealth)); healthField.SetText(healthText); healthField.Draw(globalData.screen, 1024-4, 30, sago::SagoTextField::Alignment::right, sago::SagoTextField::VerticalAlignment::top, &globalData.logicalResize); @@ -585,6 +596,23 @@ void Game::Draw(SDL_Renderer* target) { snprintf(manaText, sizeof(manaText), "Mana: %d/%d", static_cast(data->human->mana), static_cast(data->human->maxMana)); manaField.SetText(manaText); manaField.Draw(globalData.screen, 1024-4, 56, sago::SagoTextField::Alignment::right, sago::SagoTextField::VerticalAlignment::top, &globalData.logicalResize); + + // Level display + static char levelText[32]; + static sago::SagoTextField levelField; + levelField.SetHolder(globalData.dataHolder); + snprintf(levelText, sizeof(levelText), "Level: %d", data->human->level); + levelField.SetText(levelText); + levelField.Draw(globalData.screen, 1024-4, 82, sago::SagoTextField::Alignment::right, sago::SagoTextField::VerticalAlignment::top, &globalData.logicalResize); + + // XP display + static char xpText[64]; + static sago::SagoTextField xpField; + xpField.SetHolder(globalData.dataHolder); + int xpNext = xpRequiredForLevel(data->human->level + 1); + snprintf(xpText, sizeof(xpText), "XP: %d / %d", data->human->xp, xpNext); + xpField.SetText(xpText); + xpField.Draw(globalData.screen, 1024-4, 108, sago::SagoTextField::Alignment::right, sago::SagoTextField::VerticalAlignment::top, &globalData.logicalResize); } @@ -751,7 +779,7 @@ static void HandleSpawnCommand(GameRegion& gameRegion, Human* human) { spawnY = human->Y + baseRadius * std::sin(angle); } - gameRegion.SpawnMonster(def, spawnX, spawnY); + gameRegion.SpawnMonster(def, spawnX, spawnY, globalData.pendingSpawnCommand.level); // Set initial state if specified auto& spawned = gameRegion.placeables.back(); @@ -839,6 +867,23 @@ void Game::Update() { // Attack just started, apply damage to player MonsterAttackPlayer(monster, data->human.get()); } + // Award XP on monster death + if (monster->removeMe && data->human && data->human->diedAt == 0.0f) { + int xpGain = static_cast(monster->xp_reward * monster->level); + data->human->xp += xpGain; + int newLevel = levelFromXp(data->human->xp); + if (newLevel > data->human->level) { + data->human->level = newLevel; + LevelStats stats = calculateStats(100.0f, 50.0f, 1.0f, newLevel); + data->human->maxHealth = stats.maxHealth; + data->human->maxMana = stats.maxMana; + data->human->damageMultiplier = stats.damageMultiplier; + data->human->health = stats.maxHealth; // Full heal on level-up + data->human->mana = stats.maxMana; + } + globalData.player.xp = data->human->xp; + globalData.player.level = data->human->level; + } } MiscItem* item = dynamic_cast (entity.get()); if (item) { diff --git a/src/saland/GameConsoleCommand.cpp b/src/saland/GameConsoleCommand.cpp index 5df09f5..a7f6277 100644 --- a/src/saland/GameConsoleCommand.cpp +++ b/src/saland/GameConsoleCommand.cpp @@ -219,6 +219,7 @@ struct ConsoleCommandSpawn : public ConsoleCommand { // Check for optional flags std::string initialState = "roaming"; bool spread = false; + int level = 1; for (size_t i = 3; i < args.size(); ++i) { if (args[i] == "--aggressive") { initialState = "aggressive"; @@ -229,20 +230,32 @@ struct ConsoleCommandSpawn : public ConsoleCommand { else if (args[i] == "--spread") { spread = true; } + else if (args[i] == "--level" && i + 1 < args.size()) { + try { + level = std::stoi(args[i + 1]); + } + catch (...) { + throw std::runtime_error("Failed to parse level value: " + args[i + 1]); + } + if (level < 1 || level > 100) { + throw std::runtime_error("Level must be between 1 and 100"); + } + ++i; // skip the level value + } else { - throw std::runtime_error("Unknown flag: " + args[i] + ". Use --aggressive, --fleeing, or --spread"); + throw std::runtime_error("Unknown flag: " + args[i] + ". Use --aggressive, --fleeing, --spread, or --level N"); } } // Queue the spawn command (will be processed in Game.cpp) - globalData.pendingSpawnCommand = {race, count, initialState, spread}; + globalData.pendingSpawnCommand = {race, count, level, initialState, spread}; std::string location = spread ? "randomly across the map" : "around player"; - return "Spawning " + std::to_string(count) + " " + race + "(s) in " + initialState + " state " + location; + return "Spawning " + std::to_string(count) + " " + race + "(s) level " + std::to_string(level) + " in " + initialState + " state " + location; } virtual std::string helpMessage() const override { - return "Spawn enemies. Usage: spawn MONSTER_RACE COUNT [--aggressive|--fleeing] [--spread]. Example: spawn bee 8 or spawn bat 5 --aggressive --spread"; + return "Spawn enemies. Usage: spawn MONSTER_RACE COUNT [--aggressive|--fleeing] [--spread] [--level N]. Example: spawn bee 8 or spawn bat 5 --aggressive --level 3"; } }; diff --git a/src/saland/GameMonsters.cpp b/src/saland/GameMonsters.cpp index 1e68af8..2e22a03 100644 --- a/src/saland/GameMonsters.cpp +++ b/src/saland/GameMonsters.cpp @@ -76,6 +76,9 @@ static void LoadMonsterDefinitions() { else if (memberName == "speed") { def.speed = member.value.GetFloat(); } + else if (memberName == "xp_reward") { + def.xp_reward = member.value.GetFloat(); + } else if (memberName == "attack" && member.value.IsObject()) { for (const auto& attackMember : member.value.GetObject()) { std::string attackName = attackMember.name.GetString(); diff --git a/src/saland/GameRegion.cpp b/src/saland/GameRegion.cpp index 4544e18..7bf6624 100644 --- a/src/saland/GameRegion.cpp +++ b/src/saland/GameRegion.cpp @@ -24,6 +24,7 @@ https://github.com/sago007/saland #include "GameRegion.hpp" #include "GameItems.hpp" #include "GameMonsters.hpp" +#include "model/LevelSystem.hpp" #include #include @@ -38,15 +39,23 @@ static std::string createFileName(int x, int y,const std::string& worldName) { -void GameRegion::SpawnMonster(const MonsterDef& def, float destX, float destY) { +void GameRegion::SpawnMonster(const MonsterDef& def, float destX, float destY, int level) { std::shared_ptr monster = std::make_shared(); monster.get()->Radius = def.radius; monster.get()->race = def.race; monster.get()->X = destX; monster.get()->Y = destY; - monster.get()->health = def.health; monster.get()->speed = def.speed; + monster.get()->xp_reward = def.xp_reward; + monster.get()->level = level; + + // Apply level scaling to stats + LevelStats stats = calculateStats(def.health, 0.0f, def.attack.damage, level); + monster.get()->health = stats.maxHealth; + monster.get()->maxHealth = stats.maxHealth; monster.get()->attack = def.attack; + monster.get()->attack.damage = stats.damageMultiplier; // scaled damage + monster.get()->damageMultiplier = 1.0f; placeables.push_back(monster); b2BodyDef monsterBodyDef; diff --git a/src/saland/GameRegion.hpp b/src/saland/GameRegion.hpp index 322d5c6..2d3a534 100644 --- a/src/saland/GameRegion.hpp +++ b/src/saland/GameRegion.hpp @@ -37,6 +37,7 @@ struct MonsterDef { std::string race = ""; float health = 30.0f; float speed = 1.0f; + float xp_reward = 50.0f; MonsterAttack attack; }; @@ -62,7 +63,7 @@ public: std::string GetFilename() const { return mapFileName; } - void SpawnMonster(const MonsterDef& def, float destX, float destY) ; + void SpawnMonster(const MonsterDef& def, float destX, float destY, int level = 1) ; void SpawnItem(const ItemDef& def, float destX, float destY) ; void SpawnPrefab(const Prefab& prefab, int destX, int destY); void ProcessRegionFirstTimeEnter(World& world); diff --git a/src/saland/GameUpdates.cpp b/src/saland/GameUpdates.cpp index bfbb899..17c58dc 100644 --- a/src/saland/GameUpdates.cpp +++ b/src/saland/GameUpdates.cpp @@ -197,6 +197,11 @@ void ProjectileHit(Projectile* p, Placeable* target) { Monster* monster = dynamic_cast (target); if (target->destructible) { float damageAmount = p->damage.getDamage(); + // Apply damage multiplier from the entity that fired the projectile + Human* firedByHuman = dynamic_cast(p->fired_by.get()); + if (firedByHuman) { + damageAmount *= firedByHuman->damageMultiplier; + } target->health -= damageAmount; p->removeMe = true; diff --git a/src/saland/globals.hpp b/src/saland/globals.hpp index c9ce1f3..3a109a9 100644 --- a/src/saland/globals.hpp +++ b/src/saland/globals.hpp @@ -41,6 +41,7 @@ struct PlayerControls { struct SpawnCommand { std::string race; int count = 0; + int level = 1; std::string initialState; // "roaming", "aggressive", or "fleeing" bool spread = false; // If true, spawn at random locations instead of around player }; diff --git a/src/saland/model/LevelSystem.cpp b/src/saland/model/LevelSystem.cpp new file mode 100644 index 0000000..255268f --- /dev/null +++ b/src/saland/model/LevelSystem.cpp @@ -0,0 +1,27 @@ +#include "LevelSystem.hpp" + +int xpRequiredForLevel(int level) { + if (level <= 1) { + return 0; + } + return 150 * level * level; +} + +int levelFromXp(int totalXp) { + int level = 1; + while (xpRequiredForLevel(level + 1) <= totalXp) { + level++; + } + return level; +} + +LevelStats calculateStats(float baseHealth, float baseMana, float baseDamageMultiplier, int level) { + LevelStats stats; + stats.level = level; + float multiplier = 1.0f + 0.05f * std::pow(static_cast(level), 1.5f); + stats.maxHealth = baseHealth * multiplier; + stats.maxMana = baseMana * multiplier; + stats.damageMultiplier = baseDamageMultiplier * multiplier; + stats.xpToNextLevel = xpRequiredForLevel(level + 1); + return stats; +} diff --git a/src/saland/model/LevelSystem.hpp b/src/saland/model/LevelSystem.hpp new file mode 100644 index 0000000..39d2e82 --- /dev/null +++ b/src/saland/model/LevelSystem.hpp @@ -0,0 +1,51 @@ +/* + * LevelSystem - A standalone, portable level/stats calculation module. + * + * This module has no game-specific dependencies and can be reused + * across different projects. It provides: + * - XP threshold calculations + * - Level derivation from total XP + * - Stat scaling based on level (health, mana, damage multiplier) + * + * Scaling formula: base * (1 + 0.05 * level^1.5) + * XP thresholds: 150 * level^2 + */ + +#ifndef LEVELSYSTEM_HPP +#define LEVELSYSTEM_HPP + +#include + +struct LevelStats { + int level = 1; + float maxHealth = 10.0f; + float maxMana = 20.0f; + float damageMultiplier = 1.0f; + int xpToNextLevel = 150; +}; + +/** + * Returns the total XP required to reach a given level. + * Level 1 requires 0 XP. Level 2 requires 600 XP. Etc. + */ +int xpRequiredForLevel(int level); + +/** + * Derives the current level from total accumulated XP. + * A player with 0 XP is level 1. + */ +int levelFromXp(int totalXp); + +/** + * Calculates scaled stats for a given level using polynomial scaling. + * The formula is: stat = base * (1 + 0.05 * level^1.5) + * + * @param baseHealth Base maximum health at level 0/1 + * @param baseMana Base maximum mana at level 0/1 + * @param baseDamageMultiplier Base damage multiplier (typically 1.0) + * @param level The level to calculate stats for + * @return LevelStats with all computed values + */ +LevelStats calculateStats(float baseHealth, float baseMana, float baseDamageMultiplier, int level); + +#endif /* LEVELSYSTEM_HPP */ diff --git a/src/saland/model/Player.cpp b/src/saland/model/Player.cpp index 1a0951d..d5287bc 100644 --- a/src/saland/model/Player.cpp +++ b/src/saland/model/Player.cpp @@ -24,7 +24,7 @@ https://github.com/sago007/saland #include "Player.hpp" void to_json(nlohmann::json& j, const Player& p) { - j = nlohmann::json{ {"race", p.race}, {"hair", p.hair}, {"item_inventory", p.item_inventory}, {"equipped_items", p.equipped_items} }; + j = nlohmann::json{ {"race", p.race}, {"hair", p.hair}, {"item_inventory", p.item_inventory}, {"equipped_items", p.equipped_items}, {"xp", p.xp}, {"level", p.level} }; } void from_json(const nlohmann::json& j, Player& p) { @@ -36,4 +36,10 @@ void from_json(const nlohmann::json& j, Player& p) { if (j.contains("equipped_items")) { j.at("equipped_items").get_to(p.equipped_items); } + if (j.contains("xp")) { + j.at("xp").get_to(p.xp); + } + if (j.contains("level")) { + j.at("level").get_to(p.level); + } } \ No newline at end of file diff --git a/src/saland/model/Player.hpp b/src/saland/model/Player.hpp index 370e3e1..d8f4f7d 100644 --- a/src/saland/model/Player.hpp +++ b/src/saland/model/Player.hpp @@ -65,6 +65,8 @@ struct Player { } std::map item_inventory; std::vector equipped_items = {"armor_basic_pants"}; + int xp = 0; + int level = 1; }; void to_json(nlohmann::json& j, const Player& p); diff --git a/src/saland/model/placeables.hpp b/src/saland/model/placeables.hpp index 4fd0918..2729ac2 100644 --- a/src/saland/model/placeables.hpp +++ b/src/saland/model/placeables.hpp @@ -80,9 +80,11 @@ public: class Creature : public Placeable { public: + float maxHealth = 10.0; float stinema = 10.0; float mana = 20.0; float maxMana = 20.0; + float damageMultiplier = 1.0f; char direction = 'S'; bool moving = false; float moveX = 0.0; @@ -103,6 +105,8 @@ public: std::string animation = "spellcast"; float castTimeRemaining = 0; //If non-zero then we are casting a spell float castTime = 400; //Number of milliseconds it will take to complete the cast + int xp = 0; + int level = 1; }; struct MonsterAttack { @@ -124,6 +128,8 @@ public: }; std::string race = "bat"; float speed = 1.0f; + int level = 1; + float xp_reward = 50.0f; // AI logic: float aiNextThink = 0.0; State aiState = State::Roaming;