phantasia

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

commit 5bb926261fd5f67aa2d950ca708d5f0756873035
parent 316011ef13ee993d8944b69201a065cd14de1155
Author: beep <beep@wimdupont.com>
Date:   Wed, 29 Jul 2026 12:45:37 +0000

Add CTB turn order and Flash spell

Diffstat:
MREADME.adoc | 3++-
Mconfig.def.h | 16++++++++++------
Msrc/engine/world.c | 31++++++++++++++++++++++++++++++-
Msrc/engine/world.h | 5+++++
Msrc/game/main.c | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
5 files changed, 232 insertions(+), 28 deletions(-)

diff --git a/README.adoc b/README.adoc @@ -6,6 +6,7 @@ * FFVI-like top-down movement/camera feel. * No random encounters; use visible/in-world enemy encounters instead. +* CTB combat uses agility and action speed to expose and manipulate turn order. * Loot-heavy character progression with talent points and build choices. * Story should stay optional and discoverable instead of interrupting gameplay. * Keep most systems generic and engine-like so monsters, NPCs, areas, and loot can be expanded without hardcoding one-off cases. @@ -43,7 +44,7 @@ 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, battle timing and magic tuning, rewards, and controls. +starting equipment, CTB timing and spell tuning, rewards, and controls. Adding content should normally extend these configured arrays without adding another special case to the core initialization code. diff --git a/config.def.h b/config.def.h @@ -13,8 +13,12 @@ #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_BATTLE_CTB_BASE 1000 +#define PH_FLASH_MP_COST 15 +#define PH_FLASH_POWER 5 +#define PH_FLASH_DELAY_PERCENT 50 +#define PH_FLASH_ACCURACY_PENALTY 20 +#define PH_FLASH_ACCURACY_TURNS 3 #define PH_START_MAP_PATH "obj/maps/ashen-meadow.phmap" #define PH_RENDER_SMOKE_PATH "obj/render-smoke.bmp" @@ -72,9 +76,9 @@ static const PhEntityDef ph_entity_defs[] = { { .id = PH_DEF_PLAYER, .name = "Wayfarer", - .stats = { .max_hp = 40, .max_mp = 12, .strength = 5, .defense = 3, + .stats = { .max_hp = 40, .max_mp = 50, .strength = 5, .defense = 3, .magic = 4, .magic_defense = 3, .agility = 10, .luck = 5, - .evasion = 8, .accuracy = 90 }, + .evasion = 2, .accuracy = 100 }, .move_speed = 90, .asset_id = PH_ASSET_HERO, .sprite_tiles = { { 4, 0 }, { 0, 0 }, { 0, 1 } }, @@ -85,8 +89,8 @@ static const PhEntityDef ph_entity_defs[] = { .id = PH_DEF_SLIME, .name = "Mire Slime", .stats = { .max_hp = 12, .strength = 3, .defense = 1, - .magic_defense = 1, .agility = 5, .luck = 1, - .evasion = 4, .accuracy = 80 }, + .magic_defense = 1, .agility = 10, .luck = 1, + .evasion = 2, .accuracy = 100 }, .move_speed = 45, .xp_reward = 8, .loot_item_id = PH_ITEM_HEALING_POTION, diff --git a/src/engine/world.c b/src/engine/world.c @@ -481,6 +481,7 @@ ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index) PhEntity *target; PhStats attack; PhStats defense; + int accuracy; int chance; int damage; @@ -492,7 +493,9 @@ ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index) 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); + accuracy = attack.accuracy * (100 - + ph_clampi(attacker->accuracy_penalty, 0, 100)) / 100; + chance = ph_clampi(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; @@ -501,6 +504,32 @@ ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index) } int +ph_world_apply_accuracy_penalty(PhWorld *world, int entity_index, + int percent, int turns) +{ + PhEntity *entity; + + if (entity_index < 0 || entity_index >= world->entity_count || + percent <= 0 || turns <= 0) return -1; + entity = &world->entities[entity_index]; + if (!entity->active) return -1; + entity->accuracy_penalty = ph_clampi(percent, 0, 100); + entity->accuracy_turns = turns; + return 0; +} + +void +ph_world_advance_effects(PhWorld *world, int entity_index) +{ + PhEntity *entity; + + if (entity_index < 0 || entity_index >= world->entity_count) return; + entity = &world->entities[entity_index]; + if (entity->accuracy_turns > 0 && --entity->accuracy_turns == 0) + entity->accuracy_penalty = 0; +} + +int ph_world_magic_attack(PhWorld *world, int attacker_index, int target_index, int mp_cost, int power) { diff --git a/src/engine/world.h b/src/engine/world.h @@ -103,6 +103,8 @@ typedef struct { int hp; int mp; int xp; + int accuracy_penalty; + int accuracy_turns; int facing_x; int facing_y; int equipment[PH_EQUIP_COUNT]; @@ -171,6 +173,9 @@ 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_apply_accuracy_penalty(PhWorld *world, int entity_index, + int percent, int turns); +void ph_world_advance_effects(PhWorld *world, int entity_index); int ph_world_finish_encounter(PhWorld *world, int enemy_index); void ph_world_tick(PhWorld *world, PhInput input, float dt); diff --git a/src/game/main.c b/src/game/main.c @@ -38,8 +38,32 @@ #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_CTB_BASE +#define PH_BATTLE_CTB_BASE 1000 +#endif +#ifndef PH_FLASH_MP_COST +#ifdef PH_BATTLE_MAGIC_COST +#define PH_FLASH_MP_COST PH_BATTLE_MAGIC_COST +#else +#define PH_FLASH_MP_COST 15 +#endif +#endif +#ifndef PH_FLASH_POWER +#ifdef PH_BATTLE_MAGIC_POWER +#define PH_FLASH_POWER PH_BATTLE_MAGIC_POWER +#else +#define PH_FLASH_POWER 5 +#endif +#endif +#ifndef PH_FLASH_DELAY_PERCENT +#define PH_FLASH_DELAY_PERCENT 50 +#endif +#ifndef PH_FLASH_ACCURACY_PENALTY +#define PH_FLASH_ACCURACY_PENALTY 20 +#endif +#ifndef PH_FLASH_ACCURACY_TURNS +#define PH_FLASH_ACCURACY_TURNS 3 #endif #ifndef PH_BATTLE_BG_COLOR #define PH_BATTLE_BG_COLOR 44, 52, 68, 255 @@ -74,6 +98,8 @@ typedef enum { PH_BATTLE_ENEMY_ACTION, } PhBattlePhase; +enum { PH_BATTLE_ORDER_COUNT = 6 }; + typedef struct { int enemy_index; int command; @@ -82,6 +108,10 @@ typedef struct { int xp_reward; int loot_item_id; int loot_amount; + int player_ctb; + int enemy_ctb; + int current_actor; + int action_delay; float timer; PhBattlePhase phase; char message[64]; @@ -243,6 +273,17 @@ ph_run_smoke_test(void) 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_apply_accuracy_penalty(&world, enemy_index, 20, 3) < 0 || + world.entities[enemy_index].accuracy_penalty != 20 || + world.entities[enemy_index].accuracy_turns != 3) { + fprintf(stderr, "smoke test failed: battle effect setup\n"); + return 1; + } + ph_world_advance_effects(&world, enemy_index); + ph_world_advance_effects(&world, enemy_index); + ph_world_advance_effects(&world, enemy_index); + if (world.entities[enemy_index].accuracy_penalty != 0 || + world.entities[enemy_index].accuracy_turns != 0 || 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 || @@ -826,6 +867,44 @@ ph_inventory_key(PhInventoryUi *ui, PhWorld *world, SDL_Scancode key) } } +static int +ph_battle_actor_delay(const PhWorld *world, int actor) +{ + PhStats stats = ph_world_entity_stats(world, &world->entities[actor]); + int delay = PH_BATTLE_CTB_BASE / (stats.agility > 0 ? stats.agility : 1); + + return delay > 0 ? delay : 1; +} + +static void +ph_battle_start_turn(PhBattle *battle, const PhWorld *world, int actor) +{ + battle->current_actor = actor; + battle->action_delay = ph_battle_actor_delay(world, actor); + battle->resolved = 0; + if (actor == world->player_index) { + battle->phase = PH_BATTLE_COMMAND; + battle->timer = 0.0f; + } else { + battle->phase = PH_BATTLE_ENEMY_ACTION; + battle->timer = PH_BATTLE_ACTION_SECONDS; + } +} + +static void +ph_battle_next_turn(PhBattle *battle, const PhWorld *world) +{ + int previous = battle->current_actor; + int next; + + if (previous == world->player_index) battle->player_ctb += battle->action_delay; + else battle->enemy_ctb += battle->action_delay; + if (battle->player_ctb < battle->enemy_ctb) next = world->player_index; + else if (battle->enemy_ctb < battle->player_ctb) next = battle->enemy_index; + else next = previous == world->player_index ? battle->enemy_index : world->player_index; + ph_battle_start_turn(battle, world, next); +} + static void ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index) { @@ -834,6 +913,8 @@ ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index) memset(battle, 0, sizeof(*battle)); battle->enemy_index = enemy_index; + battle->current_actor = world->player_index; + battle->enemy_ctb = ph_battle_actor_delay(world, enemy_index); battle->timer = PH_BATTLE_TRANSITION_SECONDS; if (def) { battle->xp_reward = def->xp_reward; @@ -861,8 +942,9 @@ 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) { + if (battle->phase != PH_BATTLE_COMMAND || !player || + battle->current_actor != world->player_index) return; + if (battle->command == 1 && player->mp < PH_FLASH_MP_COST) { snprintf(battle->message, sizeof(battle->message), "Not enough MP."); return; } @@ -873,6 +955,39 @@ ph_battle_select(PhBattle *battle, PhWorld *world) battle->phase = PH_BATTLE_PLAYER_ACTION; battle->timer = PH_BATTLE_ACTION_SECONDS; battle->resolved = 0; + battle->action_delay = ph_battle_actor_delay(world, world->player_index); + if (battle->command == 1) + battle->action_delay = battle->action_delay * PH_FLASH_DELAY_PERCENT / 100; + if (battle->action_delay < 1) battle->action_delay = 1; +} + +static void +ph_battle_order(const PhBattle *battle, const PhWorld *world, + int order[PH_BATTLE_ORDER_COUNT]) +{ + int player_ctb = battle->player_ctb; + int enemy_ctb = battle->enemy_ctb; + int actor = battle->current_actor; + int i; + + for (i = 0; i < PH_BATTLE_ORDER_COUNT; ++i) { + int delay = ph_battle_actor_delay(world, actor); + + order[i] = actor; + if (i == 0 && actor == world->player_index) { + if (battle->phase == PH_BATTLE_PLAYER_ACTION) + delay = battle->action_delay; + else if (battle->phase == PH_BATTLE_COMMAND && battle->command == 1) + delay = delay * PH_FLASH_DELAY_PERCENT / 100; + if (delay < 1) delay = 1; + } + if (actor == world->player_index) player_ctb += delay; + else enemy_ctb += delay; + if (player_ctb < enemy_ctb) actor = world->player_index; + else if (enemy_ctb < player_ctb) actor = battle->enemy_index; + else actor = actor == world->player_index ? + battle->enemy_index : world->player_index; + } } static void @@ -888,7 +1003,10 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) 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); + battle->enemy_index, PH_FLASH_MP_COST, PH_FLASH_POWER); + if (damage >= 0) ph_world_apply_accuracy_penalty(world, + battle->enemy_index, PH_FLASH_ACCURACY_PENALTY, + PH_FLASH_ACCURACY_TURNS); } else { const PhItemDef *item = ph_world_item_def(world, battle->item_id); @@ -898,14 +1016,19 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) 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); + if (battle->command == 1 && damage >= 0) + snprintf(battle->message, sizeof(battle->message), + "Flash deals %d damage.", damage); + else + 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); + ph_world_advance_effects(world, battle->enemy_index); snprintf(battle->message, sizeof(battle->message), damage > 0 ? "%s deals %d damage." : "%s misses.", def && def->name ? def->name : "Enemy", damage); @@ -918,7 +1041,7 @@ ph_battle_update(PhBattle *battle, PhWorld *world, PhGameMode *game, float dt) battle->timer -= dt; if (battle->timer <= 0.0f) { battle->timer = 0.0f; - battle->phase = PH_BATTLE_COMMAND; + ph_battle_start_turn(battle, world, world->player_index); *game = PH_GAME_BATTLE; } return; @@ -936,16 +1059,11 @@ ph_battle_update(PhBattle *battle, PhWorld *world, PhGameMode *game, float dt) *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; + return; } + ph_battle_next_turn(battle, world); } static float @@ -999,6 +1117,7 @@ ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, 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); + int order[PH_BATTLE_ORDER_COUNT]; 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, @@ -1019,6 +1138,18 @@ ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, 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); + if (enemy->accuracy_turns > 0) { + snprintf(text, sizeof(text), "ACC -%d%% (%d)", enemy->accuracy_penalty, + enemy->accuracy_turns); + SDL_RenderDebugText(renderer, 16.0f, 42.0f, text); + } + ph_battle_order(battle, world, order); + SDL_RenderDebugText(renderer, 224.0f, 18.0f, "TURN ORDER"); + for (i = 0; i < PH_BATTLE_ORDER_COUNT; ++i) { + snprintf(text, sizeof(text), "%d %s", i + 1, + order[i] == world->player_index ? "YOU" : "ENEMY"); + SDL_RenderDebugText(renderer, 240.0f, 30.0f + (float)i * 11.0f, text); + } SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR); SDL_RenderFillRect(renderer, &panel); SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR); @@ -1032,10 +1163,14 @@ ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, } SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); SDL_RenderDebugText(renderer, 104.0f, 184.0f, battle->message); + if (battle->phase == PH_BATTLE_COMMAND && battle->command == 1) { + snprintf(text, sizeof(text), "FLASH %dMP QUICK", PH_FLASH_MP_COST); + SDL_RenderDebugText(renderer, 104.0f, 216.0f, text); + } snprintf(text, sizeof(text), "HP %d/%d", player->hp, player_stats.max_hp); - SDL_RenderDebugText(renderer, 208.0f, 204.0f, text); + SDL_RenderDebugText(renderer, 240.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); + SDL_RenderDebugText(renderer, 240.0f, 216.0f, text); } static void @@ -1189,6 +1324,7 @@ ph_run_render_smoke_test(void) int i; int enemy_index = -1; int result = 0; + int order[PH_BATTLE_ORDER_COUNT]; if (!SDL_Init(SDL_INIT_VIDEO)) { fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); @@ -1283,10 +1419,39 @@ ph_run_render_smoke_test(void) if (game != PH_GAME_OVER) result = 1; world.entities[world.player_index].hp = ph_world_entity_stats(&world, ph_world_player(&world)).max_hp; + ph_battle_begin(&battle, &world, enemy_index); game = PH_GAME_BATTLE; - battle.phase = PH_BATTLE_COMMAND; - world.entities[enemy_index].hp = 1; + ph_battle_start_turn(&battle, &world, world.player_index); + world.entities[enemy_index].hp = + ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp; + battle.command = 0; + ph_battle_order(&battle, &world, order); + if (order[0] != world.player_index || order[1] != enemy_index) { + fprintf(stderr, "render smoke test failed: normal CTB order\n"); + result = 1; + } battle.command = 1; + ph_battle_order(&battle, &world, order); + if (order[0] != world.player_index || order[1] != world.player_index) { + fprintf(stderr, "render smoke test failed: quick CTB order\n"); + result = 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_BATTLE || battle.phase != PH_BATTLE_COMMAND || + battle.current_actor != world.player_index || + ph_world_player(&world)->mp != 35 || + world.entities[enemy_index].accuracy_penalty != 20 || + world.entities[enemy_index].accuracy_turns != 3) { + fprintf(stderr, "render smoke test failed: Flash action\n"); + result = 1; + } + battle.command = 0; ph_battle_select(&battle, &world); ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,