phantasia

Phantasia - 2D SDL3 RPG prototype.
git clone git://git.beep.wimdupont.com/phantasia.git
Log | Files | Refs | README | LICENSE

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:
MREADME.adoc | 6+++---
Mconfig.def.h | 25++++++++++++++++++++-----
Mphantasia.6 | 8++++----
Msrc/engine/world.c | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/engine/world.h | 24++++++++++++++++++++++--
Msrc/game/main.c | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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;