commit 9c8d05cb
Add level system
Changed files
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 <SDL2/SDL2_gfxPrimitives.h>
@@ -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<int>(data->human->health));
+ snprintf(healthText, sizeof(healthText), "Health: %d/%d", static_cast<int>(data->human->health), static_cast<int>(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<int>(data->human->mana), static_cast<int>(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<int>(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<MiscItem*> (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 <cmath>
#include <random>
@@ -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> monster = std::make_shared<Monster>();
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<Monster*> (target);
if (target->destructible) {
float damageAmount = p->damage.getDamage();
+ // Apply damage multiplier from the entity that fired the projectile
+ Human* firedByHuman = dynamic_cast<Human*>(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<float>(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 <cmath>
+
+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<std::string, int> item_inventory;
std::vector<std::string> 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;