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:
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,