phantasia

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

commit 7dbf0b5d655f5c5d749a9550c7f08c03b13a68fb
parent 5bb926261fd5f67aa2d950ca708d5f0756873035
Author: beep <beep@wimdupont.com>
Date:   Wed, 29 Jul 2026 12:56:46 +0000

Make battle configuration authoritative

Diffstat:
MREADME.adoc | 10++++++++--
Mconfig.def.h | 13+++++++++++++
Mphantasia.6 | 6++++--
Msrc/engine/world.h | 12++++++++++++
Msrc/game/main.c | 190++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
5 files changed, 147 insertions(+), 84 deletions(-)

diff --git a/README.adoc b/README.adoc @@ -40,11 +40,17 @@ being explored. These principles should not: The first build copies `config.def.h` to `config.h`. Edit `config.h` to change local compile-time settings; it is ignored by Git and is not overwritten by -normal later builds. `config.def.h` contains the distributed defaults. +normal later builds. `config.def.h` contains the distributed defaults. The +active `config.h` is the only source of configured values; core code does not +silently supply missing gameplay, presentation, or control defaults. + +When the configuration API version changes, an older `config.h` is rejected at +compile time with a clear error. Merge the new fields from `config.def.h`, or +replace `config.h` with it if no local customizations need to be retained. Configuration currently owns the initial world definitions, entity spawns, item drops, display dimensions, asset paths and color keys, map tile choices, -starting equipment, CTB timing and spell tuning, rewards, and controls. +starting equipment, learned spells, 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 @@ -1,6 +1,8 @@ #ifndef CONFIG_H #define CONFIG_H +#define PH_CONFIG_VERSION 2 + #define PH_VIEW_W 320 #define PH_VIEW_H 240 #define PH_SCALE 3 @@ -72,6 +74,16 @@ enum { PH_ITEM_HEALING_POTION, }; +enum { PH_SPELL_FLASH = 1 }; + +static const PhSpellDef ph_spell_defs[] = { + { PH_SPELL_FLASH, "Flash", PH_FLASH_MP_COST, PH_FLASH_POWER, + PH_FLASH_DELAY_PERCENT, PH_FLASH_ACCURACY_PENALTY, + PH_FLASH_ACCURACY_TURNS }, +}; + +static const int ph_player_spells[] = { PH_SPELL_FLASH }; + static const PhEntityDef ph_entity_defs[] = { { .id = PH_DEF_PLAYER, @@ -195,6 +207,7 @@ static const struct { #define PH_KEY_MENU_RIGHT_1 SDL_SCANCODE_RIGHT #define PH_KEY_MENU_RIGHT_2 SDL_SCANCODE_L #define PH_KEY_MENU_ACCEPT SDL_SCANCODE_RETURN +#define PH_KEY_MENU_CANCEL SDL_SCANCODE_ESCAPE #endif #endif diff --git a/phantasia.6 b/phantasia.6 @@ -23,9 +23,11 @@ Open or close the character sheet. .It I Open or close the inventory. .It Arrow keys / HJKL -Move through inventory items, item actions, and battle commands. +Move through inventory items, item actions, battle commands, and learned spells. .It Enter -Open or confirm the selected inventory action or battle command. +Open or confirm the selected inventory action, battle command, or spell. +.It Escape +Return from the battle spell list to the command menu. .It Window close button Quit the game. .El diff --git a/src/engine/world.h b/src/engine/world.h @@ -3,6 +3,8 @@ #include <stddef.h> +#define PH_CONFIG_API_VERSION 2 + enum { PH_MAP_MAGIC = 0x50484d50, PH_MAP_VERSION = 2, @@ -98,6 +100,16 @@ typedef struct { } PhItemDef; typedef struct { + int id; + const char *name; + int mp_cost; + int power; + int delay_percent; + int accuracy_penalty; + int accuracy_turns; +} PhSpellDef; + +typedef struct { int type_id; PhVec2 pos; int hp; diff --git a/src/game/main.c b/src/game/main.c @@ -10,66 +10,11 @@ #include "config.h" -#if PH_USE_SDL -#ifndef PH_MENU_COLOR -#define PH_MENU_COLOR PH_SHEET_COLOR -#define PH_MENU_BORDER_COLOR PH_SHEET_BORDER_COLOR -#define PH_MENU_TEXT_COLOR PH_SHEET_TEXT_COLOR -#define PH_MENU_BONUS_COLOR PH_SHEET_BONUS_COLOR -#define PH_MENU_DISABLED_COLOR PH_SHEET_BORDER_COLOR -#endif -#ifndef PH_MENU_DISABLED_COLOR -#define PH_MENU_DISABLED_COLOR PH_MENU_BORDER_COLOR -#endif -#ifndef PH_KEY_INVENTORY -#define PH_KEY_INVENTORY SDL_SCANCODE_I -#endif -#ifndef PH_KEY_MENU_UP_1 -#define PH_KEY_MENU_UP_1 SDL_SCANCODE_UP -#define PH_KEY_MENU_UP_2 SDL_SCANCODE_K -#define PH_KEY_MENU_DOWN_1 SDL_SCANCODE_DOWN -#define PH_KEY_MENU_DOWN_2 SDL_SCANCODE_J -#define PH_KEY_MENU_LEFT_1 SDL_SCANCODE_LEFT -#define PH_KEY_MENU_LEFT_2 SDL_SCANCODE_H -#define PH_KEY_MENU_RIGHT_1 SDL_SCANCODE_RIGHT -#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 -#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 +#ifndef PH_CONFIG_VERSION +#error "config.h is outdated; merge config.def.h or replace config.h" +#elif PH_CONFIG_VERSION != PH_CONFIG_API_VERSION +#error "config.h version does not match the engine; merge config.def.h" #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 -#define PH_BATTLE_GROUND_COLOR 72, 84, 82, 255 -#endif -#endif #define LEN(a) (sizeof(a) / sizeof(0[a])) @@ -94,6 +39,7 @@ typedef enum { typedef enum { PH_BATTLE_COMMAND, + PH_BATTLE_MAGIC, PH_BATTLE_PLAYER_ACTION, PH_BATTLE_ENEMY_ACTION, } PhBattlePhase; @@ -103,6 +49,7 @@ enum { PH_BATTLE_ORDER_COUNT = 6 }; typedef struct { int enemy_index; int command; + int spell; int item_id; int resolved; int xp_reward; @@ -937,15 +884,33 @@ ph_battle_item(const PhWorld *world) return 0; } +static const PhSpellDef * +ph_spell_def(int id) +{ + size_t i; + + for (i = 0; i < LEN(ph_spell_defs); ++i) + if (ph_spell_defs[i].id == id) return &ph_spell_defs[i]; + return NULL; +} + +static const PhSpellDef * +ph_battle_spell(const PhBattle *battle) +{ + if (battle->spell < 0 || (size_t)battle->spell >= LEN(ph_player_spells)) + return NULL; + return ph_spell_def(ph_player_spells[battle->spell]); +} + static void ph_battle_select(PhBattle *battle, PhWorld *world) { - const PhEntity *player = ph_world_player(world); - - if (battle->phase != PH_BATTLE_COMMAND || !player || + if (battle->phase != PH_BATTLE_COMMAND || !ph_world_player(world) || 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."); + if (battle->command == 1) { + battle->spell = 0; + battle->phase = PH_BATTLE_MAGIC; + snprintf(battle->message, sizeof(battle->message), "Choose a spell."); return; } if (battle->command == 2 && !(battle->item_id = ph_battle_item(world))) { @@ -956,8 +921,25 @@ ph_battle_select(PhBattle *battle, PhWorld *world) 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_cast(PhBattle *battle, PhWorld *world) +{ + const PhEntity *player = ph_world_player(world); + const PhSpellDef *spell = ph_battle_spell(battle); + + if (battle->phase != PH_BATTLE_MAGIC || !player || !spell) return; + if (player->mp < spell->mp_cost) { + snprintf(battle->message, sizeof(battle->message), "Not enough MP."); + return; + } + 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) * + spell->delay_percent / 100; if (battle->action_delay < 1) battle->action_delay = 1; } @@ -972,13 +954,15 @@ ph_battle_order(const PhBattle *battle, const PhWorld *world, for (i = 0; i < PH_BATTLE_ORDER_COUNT; ++i) { int delay = ph_battle_actor_delay(world, actor); + const PhSpellDef *spell = ph_battle_spell(battle); 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; + else if (spell && (battle->phase == PH_BATTLE_MAGIC || + (battle->phase == PH_BATTLE_COMMAND && battle->command == 1))) + delay = delay * spell->delay_percent / 100; if (delay < 1) delay = 1; } if (actor == world->player_index) player_ctb += delay; @@ -994,6 +978,7 @@ static void ph_battle_resolve(PhBattle *battle, PhWorld *world) { const PhEntityDef *def; + const PhSpellDef *spell = NULL; int damage; if (battle->phase == PH_BATTLE_PLAYER_ACTION) { @@ -1002,11 +987,14 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) damage = ph_world_physical_attack(world, world->player_index, battle->enemy_index); } else if (battle->command == 1) { + spell = ph_battle_spell(battle); + if (!spell) return; damage = ph_world_magic_attack(world, world->player_index, - 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); + battle->enemy_index, spell->mp_cost, spell->power); + if (damage >= 0 && spell->accuracy_penalty > 0 && + spell->accuracy_turns > 0) + ph_world_apply_accuracy_penalty(world, battle->enemy_index, + spell->accuracy_penalty, spell->accuracy_turns); } else { const PhItemDef *item = ph_world_item_def(world, battle->item_id); @@ -1018,7 +1006,7 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) } if (battle->command == 1 && damage >= 0) snprintf(battle->message, sizeof(battle->message), - "Flash deals %d damage.", damage); + "%s deals %d damage.", spell->name, damage); else snprintf(battle->message, sizeof(battle->message), damage > 0 ? "%s takes %d damage." : "The attack misses.", @@ -1046,7 +1034,8 @@ ph_battle_update(PhBattle *battle, PhWorld *world, PhGameMode *game, float dt) } return; } - if (*game != PH_GAME_BATTLE || battle->phase == PH_BATTLE_COMMAND) return; + if (*game != PH_GAME_BATTLE || battle->phase == PH_BATTLE_COMMAND || + battle->phase == PH_BATTLE_MAGIC) return; battle->timer -= dt; if (!battle->resolved && battle->timer <= PH_BATTLE_ACTION_SECONDS * 0.5f) { ph_battle_resolve(battle, world); @@ -1115,11 +1104,14 @@ ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, 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); + const PhSpellDef *spell = ph_battle_spell(battle); 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 spell_panel = { 96.0f, 124.0f, 144.0f, + 24.0f + (float)LEN(ph_player_spells) * 12.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, @@ -1157,20 +1149,38 @@ ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, 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 ? '>' : ' ', + (battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC) && + 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); - if (battle->phase == PH_BATTLE_COMMAND && battle->command == 1) { - snprintf(text, sizeof(text), "FLASH %dMP QUICK", PH_FLASH_MP_COST); + if ((battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC) && + battle->command == 1 && spell) { + snprintf(text, sizeof(text), "%s %dMP QUICK", spell->name, spell->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, 240.0f, 204.0f, text); snprintf(text, sizeof(text), "MP %d/%d", player->mp, player_stats.max_mp); SDL_RenderDebugText(renderer, 240.0f, 216.0f, text); + if (battle->phase == PH_BATTLE_MAGIC) { + SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR); + SDL_RenderFillRect(renderer, &spell_panel); + SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR); + SDL_RenderRect(renderer, &spell_panel); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, 104.0f, 130.0f, "MAGICK"); + for (i = 0; i < (int)LEN(ph_player_spells); ++i) { + const PhSpellDef *choice = ph_spell_def(ph_player_spells[i]); + + if (!choice) continue; + snprintf(text, sizeof(text), "%c %s %dMP", + battle->spell == i ? '>' : ' ', choice->name, choice->mp_cost); + SDL_RenderDebugText(renderer, 104.0f, 144.0f + (float)i * 12.0f, text); + } + } } static void @@ -1437,6 +1447,13 @@ ph_run_render_smoke_test(void) result = 1; } ph_battle_select(&battle, &world); + if (battle.phase != PH_BATTLE_MAGIC || ph_world_player(&world)->mp != 50) { + fprintf(stderr, "render smoke test failed: Magick submenu\n"); + result = 1; + } + ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 0.0f, + PH_MENU_NONE, &inventory_ui, &game, &battle); + ph_battle_cast(&battle, &world); ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui, &game, &battle); @@ -1534,17 +1551,28 @@ 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 && - game == PH_GAME_BATTLE && battle.phase == PH_BATTLE_COMMAND) { + game == PH_GAME_BATTLE && (battle.phase == PH_BATTLE_COMMAND || + battle.phase == PH_BATTLE_MAGIC)) { 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) + if (battle.phase == PH_BATTLE_MAGIC) { + if (previous || next) + battle.spell = (battle.spell + (previous ? + (int)LEN(ph_player_spells) - 1 : 1)) % + (int)LEN(ph_player_spells); + else if (key == PH_KEY_MENU_ACCEPT) + ph_battle_cast(&battle, &world); + else if (key == PH_KEY_MENU_CANCEL) + battle.phase = PH_BATTLE_COMMAND; + } else if (previous || next) { battle.command = (battle.command + (previous ? 2 : 1)) % 3; - else if (key == PH_KEY_MENU_ACCEPT) + } 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) { @@ -1610,3 +1638,5 @@ main(int argc, char **argv) return 1; #endif } + +#endif /* PH_CONFIG_VERSION */