commit 316011ef13ee993d8944b69201a065cd14de1155
parent df6f8a604f475793cfaa0b12eef7f53d01c9a8cd
Author: beep <beep@wimdupont.com>
Date: Wed, 29 Jul 2026 12:31:30 +0000
Add initial turn-based battle system
Diffstat:
6 files changed, 609 insertions(+), 40 deletions(-)
diff --git a/README.adoc b/README.adoc
@@ -43,9 +43,9 @@ normal later builds. `config.def.h` contains the distributed defaults.
Configuration currently owns the initial world definitions, entity spawns,
item drops, display dimensions, asset paths and color keys, map tile choices,
-starting equipment, and controls. Adding content should normally extend these
-configured arrays without adding another special case to the core initialization
-code.
+starting equipment, battle timing and magic tuning, rewards, and controls.
+Adding content should normally extend these configured arrays without adding
+another special case to the core initialization code.
== Build
diff --git a/config.def.h b/config.def.h
@@ -11,6 +11,10 @@
#define PH_ENTITY_BLOCK_RADIUS 10
#define PH_PICKUP_RADIUS 18.0f
#define PH_NOTICE_SECONDS 1.25f
+#define PH_BATTLE_TRANSITION_SECONDS 0.55f
+#define PH_BATTLE_ACTION_SECONDS 0.50f
+#define PH_BATTLE_MAGIC_COST 3
+#define PH_BATTLE_MAGIC_POWER 5
#define PH_START_MAP_PATH "obj/maps/ashen-meadow.phmap"
#define PH_RENDER_SMOKE_PATH "obj/render-smoke.bmp"
@@ -26,6 +30,8 @@
#define PH_MENU_TEXT_COLOR 232, 236, 244, 255
#define PH_MENU_BONUS_COLOR 126, 220, 146, 255
#define PH_MENU_DISABLED_COLOR 104, 110, 124, 255
+#define PH_BATTLE_BG_COLOR 44, 52, 68, 255
+#define PH_BATTLE_GROUND_COLOR 72, 84, 82, 255
enum {
PH_ASSET_NONE,
@@ -42,7 +48,7 @@ static const struct {
[PH_ASSET_TILESET] = { "obj/assets/overworld_tileset_grass.bmp", 0, 0, 0 },
[PH_ASSET_HERO] = { "obj/assets/hero.bmp", 0, 0, 0 },
[PH_ASSET_ITEMS] = { "obj/assets/items.bmp", 111, 119, 109 },
- [PH_ASSET_CRITTERS] = { "obj/assets/critters.bmp", 129, 255, 94 },
+ [PH_ASSET_CRITTERS] = { "obj/assets/critters.bmp", 255, 147, 153 },
};
static const PhTileDef ph_tile_defs[] = {
@@ -66,7 +72,10 @@ static const PhEntityDef ph_entity_defs[] = {
{
.id = PH_DEF_PLAYER,
.name = "Wayfarer",
- .stats = { .max_hp = 40, .attack = 5, .defense = 3, .speed = 90 },
+ .stats = { .max_hp = 40, .max_mp = 12, .strength = 5, .defense = 3,
+ .magic = 4, .magic_defense = 3, .agility = 10, .luck = 5,
+ .evasion = 8, .accuracy = 90 },
+ .move_speed = 90,
.asset_id = PH_ASSET_HERO,
.sprite_tiles = { { 4, 0 }, { 0, 0 }, { 0, 1 } },
.blocks_movement = 0,
@@ -75,7 +84,13 @@ static const PhEntityDef ph_entity_defs[] = {
{
.id = PH_DEF_SLIME,
.name = "Mire Slime",
- .stats = { .max_hp = 12, .attack = 3, .defense = 1, .speed = 45 },
+ .stats = { .max_hp = 12, .strength = 3, .defense = 1,
+ .magic_defense = 1, .agility = 5, .luck = 1,
+ .evasion = 4, .accuracy = 80 },
+ .move_speed = 45,
+ .xp_reward = 8,
+ .loot_item_id = PH_ITEM_HEALING_POTION,
+ .loot_amount = 1,
.asset_id = PH_ASSET_CRITTERS,
.sprite_tiles = { { 12, 0 }, { 12, 0 }, { 12, 0 } },
.blocks_movement = 1,
@@ -89,7 +104,7 @@ static const PhItemDef ph_item_defs[] = {
.name = "Rusted Spear",
.description = "A worn but sturdy spear.",
.value = 12,
- .bonuses = { .attack = 2 },
+ .bonuses = { .strength = 2 },
.equip_slot = PH_EQUIP_WEAPON,
.asset_id = PH_ASSET_ITEMS,
.sprite_tile_x = 2,
@@ -100,7 +115,7 @@ static const PhItemDef ph_item_defs[] = {
.name = "Wayfarer Blade",
.description = "A reliable traveler's blade.",
.value = 20,
- .bonuses = { .attack = 3 },
+ .bonuses = { .strength = 3 },
.equip_slot = PH_EQUIP_WEAPON,
.asset_id = PH_ASSET_ITEMS,
.sprite_tile_x = 0,
diff --git a/phantasia.6 b/phantasia.6
@@ -10,8 +10,8 @@
.Op Fl -render-smoke-test
.Sh DESCRIPTION
.Nm
-is a small top-down RPG prototype with map movement, item pickup, and visible
-in-world entities.
+is a small top-down RPG prototype with map movement, item pickup, visible
+in-world encounters, and turn-based battles.
.Sh KEYBINDINGS
.Bl -tag -width "Arrow keys / WASD"
.It Arrow keys / WASD
@@ -23,9 +23,9 @@ Open or close the character sheet.
.It I
Open or close the inventory.
.It Arrow keys / HJKL
-Move through inventory items and actions.
+Move through inventory items, item actions, and battle commands.
.It Enter
-Open the selected item's action menu or confirm its selected action.
+Open or confirm the selected inventory action or battle command.
.It Window close button
Quit the game.
.El
diff --git a/src/engine/world.c b/src/engine/world.c
@@ -36,6 +36,31 @@ ph_clampf(float value, float min, float max)
return value;
}
+static int
+ph_clampi(int value, int min, int max)
+{
+ if (value < min) return min;
+ if (value > max) return max;
+ return value;
+}
+
+static PhStats
+ph_canonical_stats(PhStats stats)
+{
+ stats.strength += stats.attack;
+ stats.agility += stats.speed;
+ stats.attack = stats.strength;
+ stats.speed = stats.agility;
+ return stats;
+}
+
+static unsigned int
+ph_world_random(PhWorld *world)
+{
+ world->rng_state = world->rng_state * 1664525u + 1013904223u;
+ return world->rng_state;
+}
+
static float
ph_dist2(PhVec2 a, PhVec2 b)
{
@@ -154,6 +179,10 @@ ph_player_step_blocked(PhWorld *world, int player_index, PhVec2 pos)
}
def = ph_world_entity_def(world, blocker->type_id);
+ if (def && def->kind == PH_ENTITY_MONSTER) {
+ world->encounter_index = (int)(blocker - world->entities);
+ return 1;
+ }
if (def && def->name) {
char text[PH_NOTICE_SIZE];
@@ -206,6 +235,8 @@ ph_world_init(PhWorld *world, PhArea area, float viewport_w, float viewport_h)
world->camera.viewport_w = viewport_w;
world->camera.viewport_h = viewport_h;
world->player_index = -1;
+ world->encounter_index = -1;
+ world->rng_state = 0x5048414eu;
}
int
@@ -314,7 +345,8 @@ ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos)
memset(entity, 0, sizeof(*entity));
entity->type_id = type_id;
entity->pos = pos;
- entity->hp = def->stats.max_hp;
+ entity->hp = ph_canonical_stats(def->stats).max_hp;
+ entity->mp = ph_canonical_stats(def->stats).max_mp;
entity->facing_x = 0;
entity->facing_y = 1;
entity->active = 1;
@@ -377,8 +409,11 @@ ph_world_equip_item(PhWorld *world, int entity_index, int item_id)
entity->equipment[slot] = item_id;
after = ph_world_entity_stats(world, entity);
entity->hp += after.max_hp - before.max_hp;
+ entity->mp += after.max_mp - before.max_mp;
if (entity->hp < 0) entity->hp = 0;
if (entity->hp > after.max_hp) entity->hp = after.max_hp;
+ if (entity->mp < 0) entity->mp = 0;
+ if (entity->mp > after.max_mp) entity->mp = after.max_mp;
return 0;
}
@@ -439,6 +474,83 @@ ph_world_drop_inventory_item(PhWorld *world, int entity_index, int item_id)
return 0;
}
+int
+ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index)
+{
+ PhEntity *attacker;
+ PhEntity *target;
+ PhStats attack;
+ PhStats defense;
+ int chance;
+ int damage;
+
+ if (attacker_index < 0 || attacker_index >= world->entity_count ||
+ target_index < 0 || target_index >= world->entity_count) return -1;
+ attacker = &world->entities[attacker_index];
+ target = &world->entities[target_index];
+ if (!attacker->active || !target->active || attacker->hp <= 0 || target->hp <= 0)
+ return -1;
+ attack = ph_world_entity_stats(world, attacker);
+ defense = ph_world_entity_stats(world, target);
+ chance = ph_clampi(attack.accuracy + attack.luck / 2 - defense.evasion, 5, 100);
+ if ((int)(ph_world_random(world) % 100u) >= chance) return 0;
+ damage = attack.strength + attack.luck / 4 - defense.defense / 2;
+ damage = damage > 0 ? damage : 1;
+ target->hp = ph_clampi(target->hp - damage, 0, defense.max_hp);
+ return damage;
+}
+
+int
+ph_world_magic_attack(PhWorld *world, int attacker_index, int target_index,
+ int mp_cost, int power)
+{
+ PhEntity *attacker;
+ PhEntity *target;
+ PhStats defense;
+ int damage;
+
+ if (attacker_index < 0 || attacker_index >= world->entity_count ||
+ target_index < 0 || target_index >= world->entity_count ||
+ mp_cost < 0 || power < 0) return -1;
+ attacker = &world->entities[attacker_index];
+ target = &world->entities[target_index];
+ if (!attacker->active || !target->active || attacker->hp <= 0 ||
+ target->hp <= 0 || attacker->mp < mp_cost) return -1;
+ defense = ph_world_entity_stats(world, target);
+ damage = ph_world_entity_stats(world, attacker).magic + power -
+ defense.magic_defense / 2;
+ damage = damage > 0 ? damage : 1;
+ attacker->mp -= mp_cost;
+ target->hp = ph_clampi(target->hp - damage, 0, defense.max_hp);
+ return damage;
+}
+
+int
+ph_world_finish_encounter(PhWorld *world, int enemy_index)
+{
+ PhEntity *player;
+ PhEntity *enemy;
+ const PhEntityDef *def;
+ int item_index = -1;
+
+ if (enemy_index < 0 || enemy_index >= world->entity_count ||
+ world->player_index < 0 || world->player_index >= world->entity_count)
+ return -1;
+ player = &world->entities[world->player_index];
+ enemy = &world->entities[enemy_index];
+ def = ph_world_entity_def(world, enemy->type_id);
+ if (!def || def->kind != PH_ENTITY_MONSTER || enemy->hp > 0) return -1;
+ if (def->loot_item_id && def->loot_amount > 0) {
+ item_index = ph_item_def_index(world, def->loot_item_id);
+ if (item_index < 0) return -1;
+ }
+ player->xp += def->xp_reward;
+ if (item_index >= 0) world->inventory[item_index] += def->loot_amount;
+ enemy->active = 0;
+ world->encounter_index = -1;
+ return 0;
+}
+
void
ph_world_tick(PhWorld *world, PhInput input, float dt)
{
@@ -467,7 +579,8 @@ ph_world_tick(PhWorld *world, PhInput input, float dt)
input = ph_orthogonal_input(input);
next = player->pos;
if (input.move_x != 0 || input.move_y != 0) {
- float speed = (float)ph_world_entity_stats(world, player).speed;
+ float speed = (float)(def->move_speed > 0 ? def->move_speed :
+ ph_world_entity_stats(world, player).speed);
player->facing_x = input.move_x;
player->facing_y = input.move_y;
@@ -529,16 +642,26 @@ ph_world_entity_stats(const PhWorld *world, const PhEntity *entity)
int slot;
if (!entity || !(def = ph_world_entity_def(world, entity->type_id))) return stats;
- stats = def->stats;
+ stats = ph_canonical_stats(def->stats);
for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) {
const PhItemDef *item = ph_world_item_def(world, entity->equipment[slot]);
+ PhStats bonus;
if (!item) continue;
- stats.max_hp += item->bonuses.max_hp;
- stats.attack += item->bonuses.attack;
- stats.defense += item->bonuses.defense;
- stats.speed += item->bonuses.speed;
- }
+ bonus = ph_canonical_stats(item->bonuses);
+ stats.max_hp += bonus.max_hp;
+ stats.max_mp += bonus.max_mp;
+ stats.strength += bonus.strength;
+ stats.defense += bonus.defense;
+ stats.magic += bonus.magic;
+ stats.magic_defense += bonus.magic_defense;
+ stats.agility += bonus.agility;
+ stats.luck += bonus.luck;
+ stats.evasion += bonus.evasion;
+ stats.accuracy += bonus.accuracy;
+ }
+ stats.attack = stats.strength;
+ stats.speed = stats.agility;
return stats;
}
diff --git a/src/engine/world.h b/src/engine/world.h
@@ -48,9 +48,17 @@ typedef struct {
typedef struct {
int max_hp;
- int attack;
+ int max_mp;
+ int strength;
+ int attack; /* legacy config alias folded into strength */
int defense;
- int speed;
+ int magic;
+ int magic_defense;
+ int agility;
+ int speed; /* legacy config alias folded into agility */
+ int luck;
+ int evasion;
+ int accuracy;
} PhStats;
typedef struct {
@@ -65,6 +73,10 @@ typedef struct {
int id;
const char *name;
PhStats stats;
+ int move_speed;
+ int xp_reward;
+ int loot_item_id;
+ int loot_amount;
int asset_id;
int sprite_tiles[PH_SPRITE_DIRECTIONS][2];
int blocks_movement;
@@ -89,6 +101,8 @@ typedef struct {
int type_id;
PhVec2 pos;
int hp;
+ int mp;
+ int xp;
int facing_x;
int facing_y;
int equipment[PH_EQUIP_COUNT];
@@ -129,6 +143,8 @@ typedef struct {
int entity_count;
int ground_item_count;
int inventory[PH_MAX_ITEM_TYPES];
+ int encounter_index;
+ unsigned int rng_state;
char notice[PH_NOTICE_SIZE];
float notice_seconds;
} PhWorld;
@@ -152,6 +168,10 @@ int ph_world_equip_item(PhWorld *world, int entity_index, int item_id);
int ph_world_equip_inventory_item(PhWorld *world, int entity_index, int item_id);
int ph_world_use_inventory_item(PhWorld *world, int entity_index, int item_id);
int ph_world_drop_inventory_item(PhWorld *world, int entity_index, int item_id);
+int ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index);
+int ph_world_magic_attack(PhWorld *world, int attacker_index, int target_index,
+ int mp_cost, int power);
+int ph_world_finish_encounter(PhWorld *world, int enemy_index);
void ph_world_tick(PhWorld *world, PhInput input, float dt);
const PhEntity *ph_world_player(const PhWorld *world);
diff --git a/src/game/main.c b/src/game/main.c
@@ -35,6 +35,16 @@
#define PH_KEY_MENU_RIGHT_2 SDL_SCANCODE_L
#define PH_KEY_MENU_ACCEPT SDL_SCANCODE_RETURN
#endif
+#ifndef PH_BATTLE_TRANSITION_SECONDS
+#define PH_BATTLE_TRANSITION_SECONDS 0.55f
+#define PH_BATTLE_ACTION_SECONDS 0.50f
+#define PH_BATTLE_MAGIC_COST 3
+#define PH_BATTLE_MAGIC_POWER 5
+#endif
+#ifndef PH_BATTLE_BG_COLOR
+#define PH_BATTLE_BG_COLOR 44, 52, 68, 255
+#define PH_BATTLE_GROUND_COLOR 72, 84, 82, 255
+#endif
#endif
#define LEN(a) (sizeof(a) / sizeof(0[a]))
@@ -51,6 +61,33 @@ typedef enum {
} PhMenu;
typedef enum {
+ PH_GAME_WORLD,
+ PH_GAME_TRANSITION,
+ PH_GAME_BATTLE,
+ PH_GAME_VICTORY,
+ PH_GAME_OVER,
+} PhGameMode;
+
+typedef enum {
+ PH_BATTLE_COMMAND,
+ PH_BATTLE_PLAYER_ACTION,
+ PH_BATTLE_ENEMY_ACTION,
+} PhBattlePhase;
+
+typedef struct {
+ int enemy_index;
+ int command;
+ int item_id;
+ int resolved;
+ int xp_reward;
+ int loot_item_id;
+ int loot_amount;
+ float timer;
+ PhBattlePhase phase;
+ char message[64];
+} PhBattle;
+
+typedef enum {
PH_INVENTORY_BROWSE,
PH_INVENTORY_ACTIONS,
PH_INVENTORY_EXAMINE,
@@ -129,6 +166,7 @@ ph_run_smoke_test(void)
int blocked = -1;
size_t tile;
int player_index;
+ int enemy_index;
int i;
for (tile = 0; tile < LEN(ph_tile_defs); ++tile) {
@@ -143,8 +181,14 @@ ph_run_smoke_test(void)
tiles[5] = tiles[6] = (unsigned char)open;
ph_world_init(&world, area, 64.0f, 48.0f);
if (ph_world_add_entity_def(&world, (PhEntityDef){
- .id = 1, .name = "Tester", .stats = { .max_hp = 5, .speed = 1 },
+ .id = 1, .name = "Tester", .stats = { .max_hp = 5, .max_mp = 5,
+ .strength = 4, .magic = 3, .accuracy = 100, .speed = 1 },
.kind = PH_ENTITY_PLAYER }) < 0 ||
+ ph_world_add_entity_def(&world, (PhEntityDef){ .id = 4, .name = "Target",
+ .stats = { .max_hp = 8, .strength = 2, .defense = 1,
+ .accuracy = 100 }, .xp_reward = 7, .loot_item_id = 2,
+ .loot_amount = 1, .blocks_movement = 1,
+ .kind = PH_ENTITY_MONSTER }) < 0 ||
ph_world_add_item_def(&world, (PhItemDef){
.id = 1, .bonuses = { .max_hp = 2, .attack = 2 },
.equip_slot = PH_EQUIP_WEAPON }) < 0 ||
@@ -154,6 +198,8 @@ ph_run_smoke_test(void)
.id = 3, .equip_slot = PH_EQUIP_WEAPON }) < 0 ||
(player_index = ph_world_spawn_entity(&world, 1,
(PhVec2){ 24.0f, 24.0f })) < 0 ||
+ (enemy_index = ph_world_spawn_entity(&world, 4,
+ (PhVec2){ 1000.0f, 1000.0f })) < 0 ||
ph_world_drop_item(&world, 1, (PhVec2){ 24.0f, 24.0f }, 1) < 0 ||
ph_world_drop_item(&world, 2, (PhVec2){ 24.0f, 24.0f }, 2) < 0) {
fprintf(stderr, "smoke test failed: setup\n");
@@ -188,16 +234,28 @@ ph_run_smoke_test(void)
ph_world_item_amount(&world, 1) != 0 ||
ph_world_item_amount(&world, 2) != 0 ||
ph_world_item_amount(&world, 3) != 1 || player->hp != 4 ||
- stats.max_hp != 7 || stats.attack != 2 ||
+ stats.max_hp != 7 || stats.strength != 6 || stats.attack != 6 ||
player->equipment[PH_EQUIP_WEAPON] != 1 ||
!world.ground_items[0].active || world.ground_items[0].item_id != 2) {
fprintf(stderr, "smoke test failed: unexpected world state\n");
return 1;
}
+ world.entities[enemy_index].pos = (PhVec2){ player->pos.x - 5.0f, player->pos.y };
+ ph_world_tick(&world, (PhInput){ .move_x = -1 }, 1.0f);
+ if (world.encounter_index != enemy_index ||
+ ph_world_physical_attack(&world, player_index, enemy_index) <= 0 ||
+ ph_world_magic_attack(&world, player_index, enemy_index, 2, 4) <= 0 ||
+ world.entities[enemy_index].hp != 0 ||
+ ph_world_finish_encounter(&world, enemy_index) < 0 ||
+ player->xp != 7 || player->mp != 3 ||
+ ph_world_item_amount(&world, 2) != 1 || world.encounter_index != -1) {
+ fprintf(stderr, "smoke test failed: battle state\n");
+ return 1;
+ }
- printf("smoke player=(%.1f,%.1f) hp=%d equipment=%d\n",
+ printf("smoke player=(%.1f,%.1f) hp=%d equipment=%d xp=%d\n",
player->pos.x, player->pos.y, player->hp,
- player->equipment[PH_EQUIP_WEAPON]);
+ player->equipment[PH_EQUIP_WEAPON], player->xp);
return 0;
}
@@ -417,9 +475,15 @@ ph_format_bonuses(char *text, size_t size, PhStats bonuses)
text[0] = '\0';
ph_append_bonus(text, size, &used, bonuses.max_hp, "HP");
- ph_append_bonus(text, size, &used, bonuses.attack, "ATK");
+ ph_append_bonus(text, size, &used, bonuses.max_mp, "MP");
+ ph_append_bonus(text, size, &used, bonuses.strength + bonuses.attack, "STR");
ph_append_bonus(text, size, &used, bonuses.defense, "DEF");
- ph_append_bonus(text, size, &used, bonuses.speed, "SPD");
+ ph_append_bonus(text, size, &used, bonuses.magic, "MAG");
+ ph_append_bonus(text, size, &used, bonuses.magic_defense, "MDEF");
+ ph_append_bonus(text, size, &used, bonuses.agility + bonuses.speed, "AGI");
+ ph_append_bonus(text, size, &used, bonuses.luck, "LCK");
+ ph_append_bonus(text, size, &used, bonuses.evasion, "EVA");
+ ph_append_bonus(text, size, &used, bonuses.accuracy, "ACC");
}
static void
@@ -469,17 +533,29 @@ ph_draw_character_sheet(SDL_Renderer *renderer, const PhAssets *assets,
snprintf(text, sizeof(text), "HP %d / %d", player->hp, stats.max_hp);
SDL_RenderDebugText(renderer, 104.0f, 54.0f, text);
- snprintf(text, sizeof(text), "ATTACK %d", stats.attack);
+ snprintf(text, sizeof(text), "MP %d / %d", player->mp, stats.max_mp);
SDL_RenderDebugText(renderer, 104.0f, 66.0f, text);
- snprintf(text, sizeof(text), "DEFENSE %d", stats.defense);
+ snprintf(text, sizeof(text), "STR %d", stats.strength);
SDL_RenderDebugText(renderer, 104.0f, 78.0f, text);
- snprintf(text, sizeof(text), "SPEED %d", stats.speed);
+ snprintf(text, sizeof(text), "DEF %d", stats.defense);
SDL_RenderDebugText(renderer, 104.0f, 90.0f, text);
- SDL_RenderDebugText(renderer, 24.0f, 112.0f, "EQUIPPED");
+ snprintf(text, sizeof(text), "MAG %d", stats.magic);
+ SDL_RenderDebugText(renderer, 208.0f, 54.0f, text);
+ snprintf(text, sizeof(text), "MDEF %d", stats.magic_defense);
+ SDL_RenderDebugText(renderer, 208.0f, 66.0f, text);
+ snprintf(text, sizeof(text), "AGI %d", stats.agility);
+ SDL_RenderDebugText(renderer, 208.0f, 78.0f, text);
+ snprintf(text, sizeof(text), "LCK %d", stats.luck);
+ SDL_RenderDebugText(renderer, 208.0f, 90.0f, text);
+ snprintf(text, sizeof(text), "EVA %d", stats.evasion);
+ SDL_RenderDebugText(renderer, 208.0f, 102.0f, text);
+ snprintf(text, sizeof(text), "ACC %d", stats.accuracy);
+ SDL_RenderDebugText(renderer, 208.0f, 114.0f, text);
+ SDL_RenderDebugText(renderer, 24.0f, 128.0f, "EQUIPPED");
for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) {
const PhItemDef *item = ph_world_item_def(world, player->equipment[slot]);
- float y = 130.0f + (float)(slot - PH_EQUIP_WEAPON) * 27.0f;
+ float y = 144.0f + (float)(slot - PH_EQUIP_WEAPON) * 25.0f;
snprintf(text, sizeof(text), "%s %s", slot_names[slot], item ? item->name : "-");
SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR);
@@ -751,10 +827,268 @@ ph_inventory_key(PhInventoryUi *ui, PhWorld *world, SDL_Scancode key)
}
static void
+ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index)
+{
+ const PhEntity *enemy = &world->entities[enemy_index];
+ const PhEntityDef *def = ph_world_entity_def(world, enemy->type_id);
+
+ memset(battle, 0, sizeof(*battle));
+ battle->enemy_index = enemy_index;
+ battle->timer = PH_BATTLE_TRANSITION_SECONDS;
+ if (def) {
+ battle->xp_reward = def->xp_reward;
+ battle->loot_item_id = def->loot_item_id;
+ battle->loot_amount = def->loot_amount;
+ snprintf(battle->message, sizeof(battle->message), "%s approaches!", def->name);
+ }
+}
+
+static int
+ph_battle_item(const PhWorld *world)
+{
+ const PhEntity *player = ph_world_player(world);
+ PhStats stats = ph_world_entity_stats(world, player);
+ int i;
+
+ for (i = 0; i < world->item_def_count; ++i)
+ if (world->inventory[i] > 0 && world->item_defs[i].use != PH_ITEM_USE_NONE &&
+ player && player->hp < stats.max_hp) return world->item_defs[i].id;
+ return 0;
+}
+
+static void
+ph_battle_select(PhBattle *battle, PhWorld *world)
+{
+ const PhEntity *player = ph_world_player(world);
+
+ if (battle->phase != PH_BATTLE_COMMAND || !player) return;
+ if (battle->command == 1 && player->mp < PH_BATTLE_MAGIC_COST) {
+ snprintf(battle->message, sizeof(battle->message), "Not enough MP.");
+ return;
+ }
+ if (battle->command == 2 && !(battle->item_id = ph_battle_item(world))) {
+ snprintf(battle->message, sizeof(battle->message), "No usable item.");
+ return;
+ }
+ battle->phase = PH_BATTLE_PLAYER_ACTION;
+ battle->timer = PH_BATTLE_ACTION_SECONDS;
+ battle->resolved = 0;
+}
+
+static void
+ph_battle_resolve(PhBattle *battle, PhWorld *world)
+{
+ const PhEntityDef *def;
+ int damage;
+
+ if (battle->phase == PH_BATTLE_PLAYER_ACTION) {
+ def = ph_world_entity_def(world, world->entities[battle->enemy_index].type_id);
+ if (battle->command == 0) {
+ damage = ph_world_physical_attack(world, world->player_index,
+ battle->enemy_index);
+ } else if (battle->command == 1) {
+ damage = ph_world_magic_attack(world, world->player_index,
+ battle->enemy_index, PH_BATTLE_MAGIC_COST, PH_BATTLE_MAGIC_POWER);
+ } else {
+ const PhItemDef *item = ph_world_item_def(world, battle->item_id);
+
+ damage = ph_world_use_inventory_item(world, world->player_index,
+ battle->item_id) < 0 ? -1 : 0;
+ snprintf(battle->message, sizeof(battle->message), "Used %s.",
+ item && item->name ? item->name : "item");
+ return;
+ }
+ snprintf(battle->message, sizeof(battle->message), damage > 0 ?
+ "%s takes %d damage." : "The attack misses.",
+ def && def->name ? def->name : "Enemy", damage);
+ return;
+ }
+
+ def = ph_world_entity_def(world, world->entities[battle->enemy_index].type_id);
+ damage = ph_world_physical_attack(world, battle->enemy_index, world->player_index);
+ snprintf(battle->message, sizeof(battle->message), damage > 0 ?
+ "%s deals %d damage." : "%s misses.",
+ def && def->name ? def->name : "Enemy", damage);
+}
+
+static void
+ph_battle_update(PhBattle *battle, PhWorld *world, PhGameMode *game, float dt)
+{
+ if (*game == PH_GAME_TRANSITION) {
+ battle->timer -= dt;
+ if (battle->timer <= 0.0f) {
+ battle->timer = 0.0f;
+ battle->phase = PH_BATTLE_COMMAND;
+ *game = PH_GAME_BATTLE;
+ }
+ return;
+ }
+ if (*game != PH_GAME_BATTLE || battle->phase == PH_BATTLE_COMMAND) return;
+ battle->timer -= dt;
+ if (!battle->resolved && battle->timer <= PH_BATTLE_ACTION_SECONDS * 0.5f) {
+ ph_battle_resolve(battle, world);
+ battle->resolved = 1;
+ }
+ if (battle->timer > 0.0f) return;
+ if (battle->phase == PH_BATTLE_PLAYER_ACTION) {
+ if (world->entities[battle->enemy_index].hp <= 0) {
+ if (ph_world_finish_encounter(world, battle->enemy_index) == 0)
+ *game = PH_GAME_VICTORY;
+ return;
+ }
+ battle->phase = PH_BATTLE_ENEMY_ACTION;
+ battle->timer = PH_BATTLE_ACTION_SECONDS;
+ battle->resolved = 0;
+ } else if (world->entities[world->player_index].hp <= 0) {
+ *game = PH_GAME_OVER;
+ } else {
+ battle->phase = PH_BATTLE_COMMAND;
+ battle->timer = 0.0f;
+ battle->resolved = 0;
+ }
+}
+
+static float
+ph_battle_lunge(const PhBattle *battle, PhBattlePhase phase)
+{
+ float progress;
+
+ if (battle->phase != phase || PH_BATTLE_ACTION_SECONDS <= 0.0f) return 0.0f;
+ progress = 1.0f - battle->timer / PH_BATTLE_ACTION_SECONDS;
+ return (progress < 0.5f ? progress : 1.0f - progress) * 20.0f;
+}
+
+static void
+ph_draw_battle_entity(SDL_Renderer *renderer, const PhAssets *assets,
+ const PhWorld *world, int entity_index, SDL_FRect rect)
+{
+ const PhEntity *entity = &world->entities[entity_index];
+ const PhEntityDef *def = ph_world_entity_def(world, entity->type_id);
+ SDL_Texture *texture = NULL;
+ int x;
+ int y;
+ SDL_FlipMode flip;
+
+ if (!def) return;
+ if (def->asset_id > PH_ASSET_NONE && (size_t)def->asset_id < LEN(assets->textures))
+ texture = assets->textures[def->asset_id];
+ if (def->kind == PH_ENTITY_PLAYER) {
+ x = def->sprite_tiles[PH_SPRITE_SIDE][0];
+ y = def->sprite_tiles[PH_SPRITE_SIDE][1];
+ flip = SDL_FLIP_NONE;
+ } else {
+ ph_entity_sprite_frame(def, entity, &x, &y, &flip);
+ }
+ if (texture) ph_draw_sprite(renderer, texture, x, y, rect, flip);
+ else {
+ if (def->kind == PH_ENTITY_MONSTER)
+ SDL_SetRenderDrawColor(renderer, PH_MONSTER_COLOR);
+ else
+ SDL_SetRenderDrawColor(renderer, PH_NPC_COLOR);
+ SDL_RenderFillRect(renderer, &rect);
+ }
+}
+
+static void
+ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets,
+ const PhWorld *world, const PhBattle *battle)
+{
+ static const char *commands[] = { "FIGHT", "MAGICK", "ITEM" };
+ const PhEntity *player = ph_world_player(world);
+ const PhEntity *enemy = &world->entities[battle->enemy_index];
+ const PhEntityDef *enemy_def = ph_world_entity_def(world, enemy->type_id);
+ PhStats player_stats = ph_world_entity_stats(world, player);
+ PhStats enemy_stats = ph_world_entity_stats(world, enemy);
+ SDL_FRect ground = { 0.0f, 104.0f, PH_VIEW_W, 72.0f };
+ SDL_FRect panel = { 8.0f, 176.0f, PH_VIEW_W - 16.0f, 56.0f };
+ SDL_FRect enemy_rect = { 56.0f + ph_battle_lunge(battle,
+ PH_BATTLE_ENEMY_ACTION), 68.0f, 48.0f, 48.0f };
+ SDL_FRect player_rect = { 232.0f - ph_battle_lunge(battle,
+ PH_BATTLE_PLAYER_ACTION), 120.0f, 48.0f, 48.0f };
+ char text[96];
+ int i;
+
+ SDL_SetRenderDrawColor(renderer, PH_BATTLE_BG_COLOR);
+ SDL_RenderClear(renderer);
+ SDL_SetRenderDrawColor(renderer, PH_BATTLE_GROUND_COLOR);
+ SDL_RenderFillRect(renderer, &ground);
+ ph_draw_battle_entity(renderer, assets, world, battle->enemy_index, enemy_rect);
+ ph_draw_battle_entity(renderer, assets, world, world->player_index, player_rect);
+ SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR);
+ SDL_RenderDebugText(renderer, 16.0f, 18.0f,
+ enemy_def && enemy_def->name ? enemy_def->name : "ENEMY");
+ snprintf(text, sizeof(text), "HP %d/%d", enemy->hp, enemy_stats.max_hp);
+ SDL_RenderDebugText(renderer, 16.0f, 30.0f, text);
+ SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR);
+ SDL_RenderFillRect(renderer, &panel);
+ SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR);
+ SDL_RenderRect(renderer, &panel);
+ for (i = 0; i < (int)LEN(commands); ++i) {
+ SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR);
+ snprintf(text, sizeof(text), "%c %s",
+ battle->phase == PH_BATTLE_COMMAND && battle->command == i ? '>' : ' ',
+ commands[i]);
+ SDL_RenderDebugText(renderer, 18.0f, 184.0f + (float)i * 14.0f, text);
+ }
+ SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR);
+ SDL_RenderDebugText(renderer, 104.0f, 184.0f, battle->message);
+ snprintf(text, sizeof(text), "HP %d/%d", player->hp, player_stats.max_hp);
+ SDL_RenderDebugText(renderer, 208.0f, 204.0f, text);
+ snprintf(text, sizeof(text), "MP %d/%d", player->mp, player_stats.max_mp);
+ SDL_RenderDebugText(renderer, 208.0f, 216.0f, text);
+}
+
+static void
+ph_draw_battle_end(SDL_Renderer *renderer, const PhWorld *world,
+ const PhBattle *battle, PhGameMode game)
+{
+ const PhItemDef *loot = ph_world_item_def(world, battle->loot_item_id);
+ char text[96];
+
+ if (game == PH_GAME_VICTORY)
+ SDL_SetRenderDrawColor(renderer, PH_BATTLE_BG_COLOR);
+ else
+ SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR);
+ SDL_RenderClear(renderer);
+ SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR);
+ if (game == PH_GAME_OVER) {
+ SDL_RenderDebugText(renderer, 120.0f, 108.0f, "GAME OVER");
+ return;
+ }
+ SDL_RenderDebugText(renderer, 128.0f, 76.0f, "VICTORY!");
+ snprintf(text, sizeof(text), "XP GAINED %d", battle->xp_reward);
+ SDL_RenderDebugText(renderer, 96.0f, 106.0f, text);
+ if (loot && battle->loot_amount > 0)
+ snprintf(text, sizeof(text), "LOOT %s x%d", loot->name, battle->loot_amount);
+ else
+ snprintf(text, sizeof(text), "LOOT NONE");
+ SDL_RenderDebugText(renderer, 72.0f, 124.0f, text);
+ SDL_RenderDebugText(renderer, 104.0f, 164.0f, "ENTER TO CONTINUE");
+}
+
+static void
ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, PhWorld *world,
- PhInput input, float dt, PhMenu menu, const PhInventoryUi *inventory_ui)
+ PhInput input, float dt, PhMenu menu, const PhInventoryUi *inventory_ui,
+ PhGameMode *game, PhBattle *battle)
{
- if (menu == PH_MENU_NONE) ph_world_tick(world, input, dt);
+ if (*game == PH_GAME_WORLD && menu == PH_MENU_NONE) {
+ ph_world_tick(world, input, dt);
+ if (world->encounter_index >= 0) {
+ ph_battle_begin(battle, world, world->encounter_index);
+ *game = PH_GAME_TRANSITION;
+ }
+ }
+ ph_battle_update(battle, world, game, dt);
+ if (*game == PH_GAME_BATTLE) {
+ ph_draw_battle(renderer, assets, world, battle);
+ SDL_RenderPresent(renderer);
+ return;
+ }
+ if (*game == PH_GAME_VICTORY || *game == PH_GAME_OVER) {
+ ph_draw_battle_end(renderer, world, battle, *game);
+ SDL_RenderPresent(renderer);
+ return;
+ }
SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR);
SDL_RenderClear(renderer);
@@ -778,6 +1112,16 @@ ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, PhWorld *world,
SDL_SetRenderDrawColor(renderer, PH_NOTICE_TEXT_COLOR);
SDL_RenderDebugText(renderer, 14.0f, (float)(PH_VIEW_H - 22), world->notice);
}
+ if (*game == PH_GAME_TRANSITION) {
+ float progress = 1.0f - battle->timer / PH_BATTLE_TRANSITION_SECONDS;
+ float height = progress * PH_VIEW_H * 0.5f;
+ SDL_FRect top = { 0.0f, 0.0f, PH_VIEW_W, height };
+ SDL_FRect bottom = { 0.0f, PH_VIEW_H - height, PH_VIEW_W, height };
+
+ SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
+ SDL_RenderFillRect(renderer, &top);
+ SDL_RenderFillRect(renderer, &bottom);
+ }
SDL_RenderPresent(renderer);
}
@@ -840,7 +1184,10 @@ ph_run_render_smoke_test(void)
PhAssets assets;
PhWorld world;
PhInventoryUi inventory_ui;
+ PhGameMode game = PH_GAME_WORLD;
+ PhBattle battle = { 0 };
int i;
+ int enemy_index = -1;
int result = 0;
if (!SDL_Init(SDL_INIT_VIDEO)) {
@@ -907,7 +1254,51 @@ ph_run_render_smoke_test(void)
i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE;
ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
1.0f / 60.0f, i == 7 ? PH_MENU_CHARACTER :
- i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE, &inventory_ui);
+ i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE, &inventory_ui,
+ &game, &battle);
+ }
+ for (i = 0; i < world.entity_count; ++i) {
+ const PhEntityDef *def = ph_world_entity_def(&world, world.entities[i].type_id);
+ if (def && def->kind == PH_ENTITY_MONSTER) enemy_index = i;
+ }
+ if (enemy_index < 0) {
+ fprintf(stderr, "render smoke test failed: enemy setup\n");
+ result = 1;
+ } else {
+ world.encounter_index = enemy_index;
+ game = PH_GAME_WORLD;
+ ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 0.0f,
+ PH_MENU_NONE, &inventory_ui, &game, &battle);
+ if (game != PH_GAME_TRANSITION) result = 1;
+ ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
+ PH_BATTLE_TRANSITION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
+ &game, &battle);
+ game = PH_GAME_BATTLE;
+ battle.phase = PH_BATTLE_ENEMY_ACTION;
+ battle.timer = 0.0f;
+ battle.resolved = 1;
+ world.entities[world.player_index].hp = 0;
+ ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 0.0f,
+ PH_MENU_NONE, &inventory_ui, &game, &battle);
+ if (game != PH_GAME_OVER) result = 1;
+ world.entities[world.player_index].hp =
+ ph_world_entity_stats(&world, ph_world_player(&world)).max_hp;
+ game = PH_GAME_BATTLE;
+ battle.phase = PH_BATTLE_COMMAND;
+ world.entities[enemy_index].hp = 1;
+ battle.command = 1;
+ ph_battle_select(&battle, &world);
+ ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
+ PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
+ &game, &battle);
+ ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
+ PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
+ &game, &battle);
+ if (game != PH_GAME_VICTORY || ph_world_player(&world)->xp != battle.xp_reward ||
+ ph_world_item_amount(&world, battle.loot_item_id) != 2) {
+ fprintf(stderr, "render smoke test failed: victory rewards\n");
+ result = 1;
+ }
}
if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) {
@@ -931,6 +1322,8 @@ ph_run_game(int frame_limit)
PhAssets assets;
PhWorld world;
PhInventoryUi inventory_ui = { 0 };
+ PhGameMode game = PH_GAME_WORLD;
+ PhBattle battle = { 0 };
Uint64 last_ticks;
int frame_count = 0;
int running = 1;
@@ -976,10 +1369,27 @@ ph_run_game(int frame_limit)
if (event.type == SDL_EVENT_QUIT) {
running = 0;
} else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
- event.key.scancode == PH_KEY_CHARACTER) {
+ game == PH_GAME_BATTLE && battle.phase == PH_BATTLE_COMMAND) {
+ SDL_Scancode key = event.key.scancode;
+ int previous = key == PH_KEY_MENU_UP_1 || key == PH_KEY_MENU_UP_2 ||
+ key == PH_KEY_MENU_LEFT_1 || key == PH_KEY_MENU_LEFT_2;
+ int next = key == PH_KEY_MENU_DOWN_1 || key == PH_KEY_MENU_DOWN_2 ||
+ key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2;
+
+ if (previous || next)
+ battle.command = (battle.command + (previous ? 2 : 1)) % 3;
+ else if (key == PH_KEY_MENU_ACCEPT)
+ ph_battle_select(&battle, &world);
+ } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
+ game == PH_GAME_VICTORY &&
+ event.key.scancode == PH_KEY_MENU_ACCEPT) {
+ game = PH_GAME_WORLD;
+ menu = PH_MENU_NONE;
+ } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
+ game == PH_GAME_WORLD && event.key.scancode == PH_KEY_CHARACTER) {
menu = menu == PH_MENU_CHARACTER ? PH_MENU_NONE : PH_MENU_CHARACTER;
} else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
- event.key.scancode == PH_KEY_INVENTORY) {
+ game == PH_GAME_WORLD && event.key.scancode == PH_KEY_INVENTORY) {
if (menu == PH_MENU_INVENTORY) {
menu = PH_MENU_NONE;
} else {
@@ -987,7 +1397,7 @@ ph_run_game(int frame_limit)
ph_inventory_open(&inventory_ui, &world);
}
} else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
- menu == PH_MENU_INVENTORY) {
+ game == PH_GAME_WORLD && menu == PH_MENU_INVENTORY) {
ph_inventory_key(&inventory_ui, &world, event.key.scancode);
}
}
@@ -1000,7 +1410,8 @@ ph_run_game(int frame_limit)
last_ticks = now_ticks;
input = ph_read_input(SDL_GetKeyboardState(NULL));
- ph_render_frame(renderer, &assets, &world, input, dt, menu, &inventory_ui);
+ ph_render_frame(renderer, &assets, &world, input, dt, menu, &inventory_ui,
+ &game, &battle);
++frame_count;
if (frame_limit > 0 && frame_count >= frame_limit) {
running = 0;