phantasia

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

commit d1044343c4931528574cacee81ecd8fc4c88aaac
parent 5485df2448c3d97cdbbbc6fa49169d20cced16fb
Author: beep <beep@wimdupont.com>
Date:   Thu, 30 Jul 2026 10:22:38 +0000

Add magick book and combat targeting

Diffstat:
Mconfig.def.h | 3++-
Mphantasia.6 | 11+++++++----
Msrc/engine/battle.c | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/engine/battle.h | 29+++++++++++++++++++++++++++++
Msrc/engine/world.h | 2+-
Msrc/game/content.c | 32++++++++++++++++++++++++++------
Msrc/game/content.h | 5+++--
Msrc/game/main.c | 31++++++++++++++++++++++++++-----
Msrc/game/presentation.c | 210++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/game/presentation.h | 24++++++++++++++++++++++++
Msrc/tests/render.c | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/smoke.c | 24++++++++++++++++++++++++
12 files changed, 578 insertions(+), 38 deletions(-)

diff --git a/config.def.h b/config.def.h @@ -1,7 +1,7 @@ #ifndef CONFIG_H #define CONFIG_H -#define PH_CONFIG_VERSION 10 +#define PH_CONFIG_VERSION 11 #define PH_VIEW_W 320 #define PH_VIEW_H 240 @@ -64,6 +64,7 @@ #define PH_KEY_INTERACT_2 SDL_SCANCODE_SPACE #define PH_KEY_CHARACTER SDL_SCANCODE_C #define PH_KEY_INVENTORY SDL_SCANCODE_I +#define PH_KEY_SPELLBOOK SDL_SCANCODE_O #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 diff --git a/phantasia.6 b/phantasia.6 @@ -22,14 +22,17 @@ Interact, pick up nearby loot, or speak to a facing shopkeeper. Open or close the character sheet. .It I Open or close the inventory. +.It O +Open or close the magick book. .It Arrow keys / HJKL -Move through inventory items, item actions, battle commands, and learned spells. +Move through inventory items, item and spell actions, battle commands, learned +spells, and combat targets. .It Enter Open or confirm the selected inventory action, shop choice, battle command, or -spell. +spell action or target. .It Escape -Return one level from an inventory or battle submenu, or close an open character -or top-level inventory window. +Return one level from an inventory, magick book, or battle submenu, or close an +open character sheet or top-level menu. .It Window close button Quit the game. .El diff --git a/src/engine/battle.c b/src/engine/battle.c @@ -128,6 +128,42 @@ ph_spellbook_buy(PhSpellBook *spells, PhWorld *world, int entity_index, return 0; } +static int +ph_spell_heal(PhWorld *world, int caster_index, int target_index, + const PhSpellDef *spell) +{ + PhEntity *caster; + PhEntity *target; + PhStats stats; + int amount; + + if (!world || !spell || spell->power <= 0 || spell->mp_cost < 0 || + caster_index < 0 || caster_index >= world->entity_count || + target_index < 0 || target_index >= world->entity_count) return -1; + caster = &world->entities[caster_index]; + target = &world->entities[target_index]; + stats = ph_world_entity_stats(world, target); + if (!caster->active || !target->active || caster->vitals.mp < spell->mp_cost || + target->vitals.hp >= stats.max_hp) return -1; + amount = stats.max_hp - target->vitals.hp; + if (amount > spell->power) amount = spell->power; + target->vitals.hp += amount; + caster->vitals.mp -= spell->mp_cost; + return amount; +} + +int +ph_spellbook_use(const PhSpellBook *spells, PhWorld *world, int entity_index, + int spell_id) +{ + const PhSpellDef *spell = ph_spellbook_def(spells, spell_id); + + if (!spell || !ph_spellbook_has(spells, spell_id)) return -1; + if (spell->field_use == PH_SPELL_FIELD_HEAL) + return ph_spell_heal(world, entity_index, entity_index, spell); + return -1; +} + const PhSpellDef * ph_battle_spell(const PhBattle *battle) { @@ -136,6 +172,37 @@ ph_battle_spell(const PhBattle *battle) return ph_spell_def(battle, battle->spells.learned[battle->choice.spell]); } +int +ph_spell_battle_usable(const PhSpellDef *spell) +{ + return spell && + (spell->battle_use == PH_SPELL_BATTLE_DAMAGE || + spell->battle_use == PH_SPELL_BATTLE_HEAL) && + (spell->target == PH_SPELL_TARGET_OPPONENT || + spell->target == PH_SPELL_TARGET_ALLY); +} + +int +ph_battle_spell_step(PhBattle *battle, int direction) +{ + int index; + int step; + + if (!battle || direction == 0 || battle->spells.learned_count <= 0) return -1; + index = battle->choice.spell; + for (step = 0; step < battle->spells.learned_count; ++step) { + index += direction < 0 ? -1 : 1; + if (index < 0) index = battle->spells.learned_count - 1; + if (index >= battle->spells.learned_count) index = 0; + if (ph_spell_battle_usable(ph_spell_def(battle, + battle->spells.learned[index]))) { + battle->choice.spell = index; + return index; + } + } + return -1; +} + void ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index, PhSpellBook spells) @@ -161,7 +228,12 @@ ph_battle_select(PhBattle *battle, PhWorld *world) if (battle->phase != PH_BATTLE_COMMAND || !ph_world_player(world) || battle->turn.current_actor != world->player_index) return; if (battle->choice.command == PH_BATTLE_MAGICK) { - battle->choice.spell = 0; + battle->choice.spell = -1; + if (ph_battle_spell_step(battle, 1) < 0) { + snprintf(battle->message, sizeof(battle->message), + "No combat spells."); + return; + } battle->phase = PH_BATTLE_MAGIC; snprintf(battle->message, sizeof(battle->message), "Choose a spell."); return; @@ -171,10 +243,10 @@ ph_battle_select(PhBattle *battle, PhWorld *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; - battle->turn.action_delay = ph_battle_actor_delay(world, world->player_index); + battle->phase = PH_BATTLE_TARGET; + battle->choice.target_index = battle->choice.command == PH_BATTLE_FIGHT ? + battle->enemy_index : world->player_index; + snprintf(battle->message, sizeof(battle->message), "Choose a target."); } void @@ -183,23 +255,94 @@ 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 (battle->phase != PH_BATTLE_MAGIC || !player || + !ph_spell_battle_usable(spell)) return; if (player->vitals.mp < spell->mp_cost) { snprintf(battle->message, sizeof(battle->message), "Not enough MP."); return; } + battle->phase = PH_BATTLE_TARGET; + battle->choice.target_index = spell->target == PH_SPELL_TARGET_ALLY ? + world->player_index : battle->enemy_index; + snprintf(battle->message, sizeof(battle->message), "Choose a target."); +} + +static PhSpellTarget +ph_battle_target_kind(const PhBattle *battle) +{ + const PhSpellDef *spell; + + if (battle->choice.command == PH_BATTLE_FIGHT) + return PH_SPELL_TARGET_OPPONENT; + if (battle->choice.command == PH_BATTLE_ITEM) + return PH_SPELL_TARGET_ALLY; + spell = ph_battle_spell(battle); + return spell ? spell->target : PH_SPELL_TARGET_NONE; +} + +static int +ph_battle_target_valid(const PhBattle *battle, const PhWorld *world, int index) +{ + const PhEntityDef *def; + PhSpellTarget target = ph_battle_target_kind(battle); + + if (index < 0 || index >= world->entity_count || + !world->entities[index].active || world->entities[index].vitals.hp <= 0) + return 0; + if (target == PH_SPELL_TARGET_OPPONENT) return index == battle->enemy_index; + def = ph_world_entity_def(world, world->entities[index].type_id); + return target == PH_SPELL_TARGET_ALLY && def && + def->kind == PH_ENTITY_PLAYER && + world->entities[index].area_id == world->area_id; +} + +int +ph_battle_target_step(PhBattle *battle, const PhWorld *world, int direction) +{ + int index; + int step; + + if (!battle || !world || battle->phase != PH_BATTLE_TARGET || + direction == 0) return -1; + index = battle->choice.target_index; + for (step = 0; step < world->entity_count; ++step) { + index += direction < 0 ? -1 : 1; + if (index < 0) index = world->entity_count - 1; + if (index >= world->entity_count) index = 0; + if (ph_battle_target_valid(battle, world, index)) { + battle->choice.target_index = index; + return index; + } + } + return -1; +} + +void +ph_battle_confirm_target(PhBattle *battle, PhWorld *world) +{ + const PhSpellDef *spell = ph_battle_spell(battle); + + if (!battle || !world || battle->phase != PH_BATTLE_TARGET || + !ph_battle_target_valid(battle, world, + battle->choice.target_index)) return; battle->phase = PH_BATTLE_PLAYER_ACTION; battle->timer = PH_BATTLE_ACTION_SECONDS; battle->resolved = 0; - battle->turn.action_delay = ph_battle_actor_delay(world, world->player_index) * - spell->delay_percent / 100; + battle->turn.action_delay = ph_battle_actor_delay(world, world->player_index); + if (battle->choice.command == PH_BATTLE_MAGICK && spell) + battle->turn.action_delay = battle->turn.action_delay * + spell->delay_percent / 100; if (battle->turn.action_delay < 1) battle->turn.action_delay = 1; } void ph_battle_cancel(PhBattle *battle) { - if (battle->phase == PH_BATTLE_MAGIC) battle->phase = PH_BATTLE_COMMAND; + if (battle->phase == PH_BATTLE_TARGET) + battle->phase = battle->choice.command == PH_BATTLE_MAGICK ? + PH_BATTLE_MAGIC : PH_BATTLE_COMMAND; + else if (battle->phase == PH_BATTLE_MAGIC) + battle->phase = PH_BATTLE_COMMAND; } void @@ -219,7 +362,8 @@ ph_battle_order(const PhBattle *battle, const PhWorld *world, if (i == 0 && actor == world->player_index) { if (battle->phase == PH_BATTLE_PLAYER_ACTION) delay = battle->turn.action_delay; - else if (spell && battle->phase == PH_BATTLE_MAGIC) + else if (spell && (battle->phase == PH_BATTLE_MAGIC || + battle->phase == PH_BATTLE_TARGET)) delay = delay * spell->delay_percent / 100; if (delay < 1) delay = 1; } @@ -240,16 +384,25 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) def = ph_world_entity_def(world, world->entities[battle->enemy_index].type_id); if (battle->choice.command == PH_BATTLE_FIGHT) { damage = ph_world_physical_attack(world, world->player_index, - battle->enemy_index); + battle->choice.target_index); } else if (battle->choice.command == PH_BATTLE_MAGICK) { spell = ph_battle_spell(battle); if (!spell) return; - damage = ph_world_magic_attack(world, world->player_index, - battle->enemy_index, spell->mp_cost, spell->power); - if (damage >= 0 && spell->accuracy.value > 0 && - spell->accuracy.turns > 0) - ph_world_apply_accuracy_penalty(world, battle->enemy_index, - spell->accuracy.value, spell->accuracy.turns); + if (spell->battle_use == PH_SPELL_BATTLE_DAMAGE) { + damage = ph_world_magic_attack(world, world->player_index, + battle->choice.target_index, spell->mp_cost, spell->power); + if (damage >= 0 && spell->accuracy.value > 0 && + spell->accuracy.turns > 0) + ph_world_apply_accuracy_penalty(world, battle->choice.target_index, + spell->accuracy.value, spell->accuracy.turns); + } else if (spell->battle_use == PH_SPELL_BATTLE_HEAL) { + damage = ph_spell_heal(world, world->player_index, + battle->choice.target_index, spell); + snprintf(battle->message, sizeof(battle->message), + damage >= 0 ? "%s restores %d HP." : "%s has no effect.", + spell->name, damage); + return; + } else return; } else { const PhItemDef *item = ph_world_item_def(world, battle->choice.item_id); @@ -287,7 +440,8 @@ ph_battle_update(PhBattle *battle, PhWorld *world, float dt, int transition) ph_battle_start_turn(battle, world, world->player_index); return PH_BATTLE_READY; } - if (battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC) + if (battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC || + battle->phase == PH_BATTLE_TARGET) return PH_BATTLE_CONTINUE; battle->timer -= dt; if (!battle->resolved && battle->timer <= PH_BATTLE_ACTION_SECONDS * diff --git a/src/engine/battle.h b/src/engine/battle.h @@ -8,6 +8,7 @@ enum { PH_BATTLE_ORDER_COUNT = 6, PH_SPELL_MAX = 16 }; typedef enum { PH_BATTLE_COMMAND, PH_BATTLE_MAGIC, + PH_BATTLE_TARGET, PH_BATTLE_PLAYER_ACTION, PH_BATTLE_ENEMY_ACTION, } PhBattlePhase; @@ -26,9 +27,30 @@ typedef enum { PH_BATTLE_COMMAND_COUNT, } PhBattleCommand; +typedef enum { + PH_SPELL_BATTLE_NONE, + PH_SPELL_BATTLE_DAMAGE, + PH_SPELL_BATTLE_HEAL, +} PhSpellBattleUse; + +typedef enum { + PH_SPELL_FIELD_NONE, + PH_SPELL_FIELD_HEAL, +} PhSpellFieldUse; + +typedef enum { + PH_SPELL_TARGET_NONE, + PH_SPELL_TARGET_OPPONENT, + PH_SPELL_TARGET_ALLY, +} PhSpellTarget; + typedef struct { int id; const char *name; + const char *description; + PhSpellBattleUse battle_use; + PhSpellFieldUse field_use; + PhSpellTarget target; int mp_cost; int power; int delay_percent; @@ -47,6 +69,7 @@ typedef struct { PhBattleCommand command; int spell; int item_id; + int target_index; } PhBattleChoice; typedef struct { @@ -71,7 +94,10 @@ typedef struct { void ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index, PhSpellBook spells); void ph_battle_select(PhBattle *battle, PhWorld *world); +int ph_battle_spell_step(PhBattle *battle, int direction); void ph_battle_cast(PhBattle *battle, PhWorld *world); +int ph_battle_target_step(PhBattle *battle, const PhWorld *world, int direction); +void ph_battle_confirm_target(PhBattle *battle, PhWorld *world); void ph_battle_cancel(PhBattle *battle); PhBattleResult ph_battle_update(PhBattle *battle, PhWorld *world, float dt, int transition); @@ -83,7 +109,10 @@ int ph_spellbook_has(const PhSpellBook *spells, int id); int ph_spellbook_learn(PhSpellBook *spells, int id); int ph_spellbook_buy(PhSpellBook *spells, PhWorld *world, int entity_index, int spell_id); +int ph_spellbook_use(const PhSpellBook *spells, PhWorld *world, int entity_index, + int spell_id); const PhSpellDef *ph_battle_spell(const PhBattle *battle); +int ph_spell_battle_usable(const PhSpellDef *spell); float ph_battle_lunge(const PhBattle *battle, PhBattlePhase phase); #endif diff --git a/src/engine/world.h b/src/engine/world.h @@ -3,7 +3,7 @@ #include <stddef.h> -#define PH_CONFIG_API_VERSION 10 +#define PH_CONFIG_API_VERSION 11 enum { PH_MAP_MAGIC = 0x50484d50, diff --git a/src/game/content.c b/src/game/content.c @@ -88,21 +88,41 @@ const PhTileDef ph_tile_defs[PH_TILE_DEF_COUNT] = { }; const PhSpellDef ph_spell_defs[PH_SPELL_DEF_COUNT] = { - { .id = PH_SPELL_FLASH, .name = "Flash", .mp_cost = 15, .power = 5, + { .id = PH_SPELL_FLASH, .name = "Flash", + .description = "A blinding spark that hastens the caster and hinders its target.", + .battle_use = PH_SPELL_BATTLE_DAMAGE, + .target = PH_SPELL_TARGET_OPPONENT, .mp_cost = 15, .power = 5, .delay_percent = 50, .accuracy = { 20, 3 } }, - { .id = PH_SPELL_EMBER, .name = "Ember", .mp_cost = 8, .power = 7, + { .id = PH_SPELL_HEAL, .name = "Heal", + .description = "Restorative magick that mends wounds in or out of battle.", + .battle_use = PH_SPELL_BATTLE_HEAL, .field_use = PH_SPELL_FIELD_HEAL, + .target = PH_SPELL_TARGET_ALLY, + .mp_cost = 10, .power = 18, .delay_percent = 100 }, + { .id = PH_SPELL_EMBER, .name = "Ember", + .description = "A small, reliable flame cast at one enemy.", + .battle_use = PH_SPELL_BATTLE_DAMAGE, + .target = PH_SPELL_TARGET_OPPONENT, .mp_cost = 8, .power = 7, .delay_percent = 100, .buy_value = 24 }, - { .id = PH_SPELL_DAZE, .name = "Daze", .mp_cost = 12, .power = 4, + { .id = PH_SPELL_DAZE, .name = "Daze", + .description = "A disorienting blast that makes an enemy less accurate.", + .battle_use = PH_SPELL_BATTLE_DAMAGE, + .target = PH_SPELL_TARGET_OPPONENT, .mp_cost = 12, .power = 4, .delay_percent = 85, .buy_value = 48, .accuracy = { 15, 2 } }, - { .id = PH_SPELL_INFERNO, .name = "Inferno", .mp_cost = 28, .power = 24, + { .id = PH_SPELL_INFERNO, .name = "Inferno", + .description = "A fierce conflagration with a long casting delay.", + .battle_use = PH_SPELL_BATTLE_DAMAGE, + .target = PH_SPELL_TARGET_OPPONENT, .mp_cost = 28, .power = 24, .delay_percent = 140, .buy_value = 600 }, - { .id = PH_SPELL_METEOR, .name = "Meteor", .mp_cost = 45, .power = 40, + { .id = PH_SPELL_METEOR, .name = "Meteor", + .description = "Forbidden starfire of overwhelming power and delay.", + .battle_use = PH_SPELL_BATTLE_DAMAGE, + .target = PH_SPELL_TARGET_OPPONENT, .mp_cost = 45, .power = 40, .delay_percent = 180, .buy_value = 2400 }, }; const PhSpellBook ph_player_spellbook = { .defs = ph_spell_defs, - .learned = { PH_SPELL_FLASH }, + .learned = { PH_SPELL_FLASH, PH_SPELL_HEAL }, .def_count = PH_SPELL_DEF_COUNT, .learned_count = PH_PLAYER_SPELL_COUNT, }; diff --git a/src/game/content.h b/src/game/content.h @@ -82,12 +82,13 @@ enum { enum { PH_SPELL_FLASH = 1, + PH_SPELL_HEAL, PH_SPELL_EMBER, PH_SPELL_DAZE, PH_SPELL_INFERNO, PH_SPELL_METEOR, - PH_SPELL_DEF_COUNT = 5, - PH_PLAYER_SPELL_COUNT = 1, + PH_SPELL_DEF_COUNT = 6, + PH_PLAYER_SPELL_COUNT = 2, }; enum { diff --git a/src/game/main.c b/src/game/main.c @@ -95,6 +95,7 @@ ph_run_game(void) PhRenderAssets assets; PhWorld world; PhInventoryUi inventory_ui = { 0 }; + PhSpellbookUi spellbook_ui = { 0 }; PhShopUi shop_ui = { 0 }; PhGameMode game = PH_GAME_WORLD; PhBattle battle = { 0 }; @@ -102,6 +103,7 @@ ph_run_game(void) PhGameContext context = { .world = &world, .inventory = &inventory_ui, + .spellbook = &spellbook_ui, .shop = &shop_ui, .mode = &game, .battle = &battle, @@ -148,18 +150,24 @@ ph_run_game(void) running = 0; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_BATTLE && (battle.phase == PH_BATTLE_COMMAND || - battle.phase == PH_BATTLE_MAGIC)) { + battle.phase == PH_BATTLE_MAGIC || + battle.phase == PH_BATTLE_TARGET)) { 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 (battle.phase == PH_BATTLE_MAGIC) { + if (battle.phase == PH_BATTLE_TARGET) { + if (previous || next) + ph_battle_target_step(&battle, &world, previous ? -1 : 1); + else if (key == PH_KEY_MENU_ACCEPT) + ph_battle_confirm_target(&battle, &world); + else if (key == PH_KEY_MENU_CANCEL) + ph_battle_cancel(&battle); + } else if (battle.phase == PH_BATTLE_MAGIC) { if ((previous || next) && battle.spells.learned_count > 0) - battle.choice.spell = (battle.choice.spell + (previous ? - battle.spells.learned_count - 1 : 1)) % - battle.spells.learned_count; + ph_battle_spell_step(&battle, previous ? -1 : 1); else if (key == PH_KEY_MENU_ACCEPT) ph_battle_cast(&battle, &world); else if (key == PH_KEY_MENU_CANCEL) @@ -198,6 +206,19 @@ ph_run_game(void) if (ph_inventory_key(&inventory_ui, &world, event.key.scancode)) menu = PH_MENU_NONE; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && + game == PH_GAME_WORLD && + (menu == PH_MENU_NONE || menu == PH_MENU_SPELLBOOK) && + event.key.scancode == PH_KEY_SPELLBOOK) { + if (menu == PH_MENU_SPELLBOOK) menu = PH_MENU_NONE; + else { + menu = PH_MENU_SPELLBOOK; + ph_spellbook_open(&spellbook_ui, &spells); + } + } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && + game == PH_GAME_WORLD && menu == PH_MENU_SPELLBOOK) { + if (ph_spellbook_key(&spellbook_ui, &world, &spells, + event.key.scancode)) menu = PH_MENU_NONE; + } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && game == PH_GAME_WORLD && menu == PH_MENU_SHOP) { if (ph_shop_key(&shop_ui, &world, &spells, event.key.scancode)) menu = PH_MENU_NONE; diff --git a/src/game/presentation.c b/src/game/presentation.c @@ -459,6 +459,188 @@ ph_inventory_key(PhInventoryUi *ui, PhWorld *world, SDL_Scancode key) } void +ph_spellbook_open(PhSpellbookUi *ui, const PhSpellBook *spells) +{ + memset(ui, 0, sizeof(*ui)); + ui->selected = spells && spells->learned_count > 0 ? 0 : -1; +} + +static int +ph_spellbook_action_enabled(const PhSpellDef *spell, int action) +{ + return action == PH_SPELLBOOK_EXAMINE_ACTION || + (spell && spell->field_use != PH_SPELL_FIELD_NONE); +} + +static int +ph_spellbook_action_step(const PhSpellDef *spell, int selected, int direction) +{ + int action; + int step; + + for (step = 1; step <= PH_SPELLBOOK_ACTION_COUNT; ++step) { + action = (selected + (direction < 0 ? + PH_SPELLBOOK_ACTION_COUNT - step : step)) % PH_SPELLBOOK_ACTION_COUNT; + if (ph_spellbook_action_enabled(spell, action)) return action; + } + return selected; +} + +static void +ph_spellbook_action(PhSpellbookUi *ui, PhWorld *world, PhSpellBook *spells) +{ + const PhSpellDef *spell; + int amount; + + if (ui->selected < 0 || ui->selected >= spells->learned_count) return; + spell = ph_spellbook_def(spells, spells->learned[ui->selected]); + if (!spell) return; + if (ui->option == PH_SPELLBOOK_EXAMINE_ACTION) { + ui->mode = PH_SPELLBOOK_EXAMINE; + ui->status[0] = '\0'; + return; + } + amount = ph_spellbook_use(spells, world, world->player_index, spell->id); + if (amount < 0) + snprintf(ui->status, sizeof(ui->status), "Cannot use %s.", spell->name); + else + snprintf(ui->status, sizeof(ui->status), "Restored %d HP.", amount); + ui->mode = PH_SPELLBOOK_BROWSE; +} + +static void +ph_draw_spellbook_popup(SDL_Renderer *renderer, const PhSpellBook *spells, + const PhSpellbookUi *ui) +{ + const PhSpellDef *spell = ph_spellbook_def(spells, + spells->learned[ui->selected]); + SDL_FRect popup = ui->mode == PH_SPELLBOOK_EXAMINE ? + (SDL_FRect){ 40.0f, 48.0f, PH_VIEW_W - 64.0f, 124.0f } : + (SDL_FRect){ PH_VIEW_W - 164.0f, 48.0f, 140.0f, 82.0f }; + char text[96]; + int action; + + if (!spell) return; + SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR); + SDL_RenderFillRect(renderer, &popup); + SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR); + SDL_RenderRect(renderer, &popup); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + if (ui->mode == PH_SPELLBOOK_EXAMINE) { + SDL_RenderDebugText(renderer, 52.0f, 58.0f, spell->name); + ph_draw_wrapped_text(renderer, 52.0f, 76.0f, popup.w - 24.0f, 12.0f, + spell->description ? spell->description : "No description."); + snprintf(text, sizeof(text), "%d MP POWER %d DELAY %d%%", + spell->mp_cost, spell->power, spell->delay_percent); + SDL_RenderDebugText(renderer, 52.0f, 126.0f, text); + SDL_RenderDebugText(renderer, 52.0f, 148.0f, "ENTER BACK"); + return; + } + SDL_RenderDebugText(renderer, ph_ui_right(152.0f), 58.0f, "SPELL ACTION"); + for (action = 0; action < PH_SPELLBOOK_ACTION_COUNT; ++action) { + if (ph_spellbook_action_enabled(spell, action)) + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + else + SDL_SetRenderDrawColor(renderer, PH_MENU_DISABLED_COLOR); + snprintf(text, sizeof(text), "%c %s", ui->option == action ? '>' : ' ', + action == PH_SPELLBOOK_USE ? "USE" : "EXAMINE"); + SDL_RenderDebugText(renderer, ph_ui_right(152.0f), + 78.0f + (float)action * 18.0f, text); + } +} + +static void +ph_draw_spellbook(SDL_Renderer *renderer, const PhWorld *world, + const PhSpellBook *spells, const PhSpellbookUi *ui) +{ + const PhEntity *player = ph_world_player(world); + PhStats stats = ph_world_entity_stats(world, player); + char text[96]; + int page = ui->selected < 0 ? 0 : ui->selected / 6; + int i; + + ph_draw_menu(renderer, "MAGICK BOOK", PH_KEY_SPELLBOOK); + if (player) { + snprintf(text, sizeof(text), "MP %d/%d", player->vitals.mp, stats.max_mp); + SDL_RenderDebugText(renderer, ph_ui_right(96.0f), 22.0f, text); + } + for (i = page * 6; i < spells->learned_count && i < page * 6 + 6; ++i) { + const PhSpellDef *spell = ph_spellbook_def(spells, spells->learned[i]); + float y = 48.0f + (float)(i - page * 6) * 24.0f; + + if (!spell) continue; + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + snprintf(text, sizeof(text), "%c %-14s %2d MP", + ui->selected == i ? '>' : ' ', spell->name, spell->mp_cost); + SDL_RenderDebugText(renderer, 24.0f, y, text); + SDL_SetRenderDrawColor(renderer, PH_MENU_BONUS_COLOR); + snprintf(text, sizeof(text), "%s POWER %d DELAY %d%%", + spell->field_use != PH_SPELL_FIELD_NONE ? "FIELD" : "BATTLE", + spell->power, spell->delay_percent); + SDL_RenderDebugText(renderer, 40.0f, y + 10.0f, text); + } + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + if (spells->learned_count <= 0) + SDL_RenderDebugText(renderer, 24.0f, 48.0f, "NO SPELLS LEARNED"); + if (spells->learned_count > 6) { + snprintf(text, sizeof(text), "PAGE %d/%d", page + 1, + (spells->learned_count + 5) / 6); + SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(56.0f), text); + } + if (ui->status[0]) + SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(40.0f), ui->status); + SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(24.0f), "ENTER SELECT"); + if (ui->selected >= 0 && ui->mode != PH_SPELLBOOK_BROWSE) + ph_draw_spellbook_popup(renderer, spells, ui); +} + +int +ph_spellbook_key(PhSpellbookUi *ui, PhWorld *world, PhSpellBook *spells, + SDL_Scancode key) +{ + 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; + const PhSpellDef *spell = ui->selected >= 0 && + ui->selected < spells->learned_count ? ph_spellbook_def(spells, + spells->learned[ui->selected]) : NULL; + + if (key == PH_KEY_MENU_CANCEL) { + if (ui->mode == PH_SPELLBOOK_EXAMINE) + ui->mode = PH_SPELLBOOK_ACTIONS; + else if (ui->mode == PH_SPELLBOOK_ACTIONS) + ui->mode = PH_SPELLBOOK_BROWSE; + else return 1; + ui->status[0] = '\0'; + return 0; + } + if (key == PH_KEY_MENU_ACCEPT) { + if (ui->mode == PH_SPELLBOOK_BROWSE && spell) { + ui->mode = PH_SPELLBOOK_ACTIONS; + ui->option = ph_spellbook_action_enabled(spell, PH_SPELLBOOK_USE) ? + PH_SPELLBOOK_USE : PH_SPELLBOOK_EXAMINE_ACTION; + ui->status[0] = '\0'; + } else if (ui->mode == PH_SPELLBOOK_ACTIONS) { + ph_spellbook_action(ui, world, spells); + } else if (ui->mode == PH_SPELLBOOK_EXAMINE) { + ui->mode = PH_SPELLBOOK_ACTIONS; + } + return 0; + } + if ((!previous && !next) || ui->mode == PH_SPELLBOOK_EXAMINE) return 0; + if (ui->mode == PH_SPELLBOOK_ACTIONS) { + ui->option = ph_spellbook_action_step(spell, ui->option, + previous ? -1 : 1); + } else if (spells->learned_count > 0) { + ui->selected = (ui->selected + (previous ? + spells->learned_count - 1 : 1)) % spells->learned_count; + ui->status[0] = '\0'; + } + return 0; +} + +void ph_shop_open(PhShopUi *ui, int shop_id) { memset(ui, 0, sizeof(*ui)); @@ -706,6 +888,17 @@ ph_draw_battle(SDL_Renderer *renderer, const PhRenderAssets *assets, 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); + if (battle->phase == PH_BATTLE_TARGET) { + SDL_FRect target_rect = battle->choice.target_index == world->player_index ? + player_rect : enemy_rect; + + target_rect.x -= 3.0f; + target_rect.y -= 3.0f; + target_rect.w += 6.0f; + target_rect.h += 6.0f; + SDL_SetRenderDrawColor(renderer, PH_MENU_BONUS_COLOR); + SDL_RenderRect(renderer, &target_rect); + } SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); SDL_RenderDebugText(renderer, 16.0f, 18.0f, enemy_def && enemy_def->name ? enemy_def->name : "ENEMY"); @@ -731,7 +924,8 @@ ph_draw_battle(SDL_Renderer *renderer, const PhRenderAssets *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->phase == PH_BATTLE_MAGIC) && + (battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC || + battle->phase == PH_BATTLE_TARGET) && battle->choice.command == (PhBattleCommand)i ? '>' : ' ', commands[i]); SDL_RenderDebugText(renderer, 18.0f, @@ -739,6 +933,14 @@ ph_draw_battle(SDL_Renderer *renderer, const PhRenderAssets *assets, } SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); SDL_RenderDebugText(renderer, 104.0f, panel.y + 8.0f, battle->message); + if (battle->phase == PH_BATTLE_TARGET) { + const PhEntityDef *target_def = ph_world_entity_def(world, + world->entities[battle->choice.target_index].type_id); + + snprintf(text, sizeof(text), "TARGET: %s ENTER CONFIRM", + target_def && target_def->name ? target_def->name : "UNKNOWN"); + SDL_RenderDebugText(renderer, 104.0f, panel.y + 24.0f, text); + } snprintf(text, sizeof(text), "HP %d/%d", player->vitals.hp, player_stats.max_hp); SDL_RenderDebugText(renderer, ph_ui_right(80.0f), ph_ui_bottom(36.0f), text); snprintf(text, sizeof(text), "MP %d/%d", player->vitals.mp, player_stats.max_mp); @@ -756,6 +958,10 @@ ph_draw_battle(SDL_Renderer *renderer, const PhRenderAssets *assets, battle->spells.learned[i]); if (!choice) continue; + if (ph_spell_battle_usable(choice)) + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + else + SDL_SetRenderDrawColor(renderer, PH_MENU_DISABLED_COLOR); snprintf(text, sizeof(text), "%c %s %dMP%s", battle->choice.spell == i ? '>' : ' ', choice->name, choice->mp_cost, choice->delay_percent < 100 ? " QUICK" : ""); @@ -825,6 +1031,8 @@ ph_present_frame(SDL_Renderer *renderer, const PhRenderAssets *assets, ph_draw_character_sheet(renderer, assets, world); } else if (context->menu == PH_MENU_INVENTORY) { ph_draw_inventory(renderer, assets, world, context->inventory); + } else if (context->menu == PH_MENU_SPELLBOOK) { + ph_draw_spellbook(renderer, world, context->spells, context->spellbook); } else if (context->menu == PH_MENU_SHOP) { ph_draw_shop(renderer, world, context->spells, context->shop); } else if (world->notice.seconds > 0.0f && world->notice.text[0] != '\0') { diff --git a/src/game/presentation.h b/src/game/presentation.h @@ -12,6 +12,7 @@ typedef enum { PH_MENU_NONE, PH_MENU_CHARACTER, PH_MENU_INVENTORY, + PH_MENU_SPELLBOOK, PH_MENU_SHOP, } PhMenu; @@ -45,6 +46,25 @@ typedef struct { } PhInventoryUi; typedef enum { + PH_SPELLBOOK_BROWSE, + PH_SPELLBOOK_ACTIONS, + PH_SPELLBOOK_EXAMINE, +} PhSpellbookMode; + +typedef enum { + PH_SPELLBOOK_USE, + PH_SPELLBOOK_EXAMINE_ACTION, + PH_SPELLBOOK_ACTION_COUNT, +} PhSpellbookAction; + +typedef struct { + int selected; + int option; + PhSpellbookMode mode; + char status[64]; +} PhSpellbookUi; + +typedef enum { PH_SHOP_ROOT, PH_SHOP_BUY, PH_SHOP_SELL, @@ -61,6 +81,7 @@ typedef struct { typedef struct { PhWorld *world; PhInventoryUi *inventory; + PhSpellbookUi *spellbook; PhShopUi *shop; PhGameMode *mode; PhBattle *battle; @@ -71,6 +92,9 @@ typedef struct { int ph_inventory_step(const PhWorld *world, int selected, int direction); void ph_inventory_open(PhInventoryUi *ui, const PhWorld *world); int ph_inventory_key(PhInventoryUi *ui, PhWorld *world, SDL_Scancode key); +void ph_spellbook_open(PhSpellbookUi *ui, const PhSpellBook *spells); +int ph_spellbook_key(PhSpellbookUi *ui, PhWorld *world, PhSpellBook *spells, + SDL_Scancode key); void ph_shop_open(PhShopUi *ui, int shop_id); PhMenu ph_interaction_open(PhShopUi *shop_ui, int interaction_id); int ph_shop_key(PhShopUi *ui, PhWorld *world, PhSpellBook *spells, diff --git a/src/tests/render.c b/src/tests/render.c @@ -16,6 +16,7 @@ main(void) PhRenderAssets assets; PhWorld world; PhInventoryUi inventory_ui; + PhSpellbookUi spellbook_ui; PhShopUi shop_ui = { 0 }; PhGameMode game = PH_GAME_WORLD; PhBattle battle = { 0 }; @@ -23,6 +24,7 @@ main(void) PhGameContext context = { .world = &world, .inventory = &inventory_ui, + .spellbook = &spellbook_ui, .shop = &shop_ui, .mode = &game, .battle = &battle, @@ -130,6 +132,32 @@ main(void) i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE; ph_present_frame(renderer, &assets, &context); } + ph_spellbook_open(&spellbook_ui, &spells); + ph_spellbook_key(&spellbook_ui, &world, &spells, PH_KEY_MENU_ACCEPT); + if (spellbook_ui.option != PH_SPELLBOOK_EXAMINE_ACTION) { + fprintf(stderr, "render smoke test failed: disabled field spell\n"); + result = 1; + } + spellbook_ui.mode = PH_SPELLBOOK_BROWSE; + spellbook_ui.selected = 1; + ph_spellbook_key(&spellbook_ui, &world, &spells, PH_KEY_MENU_ACCEPT); + if (spellbook_ui.option != PH_SPELLBOOK_USE) result = 1; + world.entities[world.player_index].vitals.hp -= 10; + ph_spellbook_key(&spellbook_ui, &world, &spells, PH_KEY_MENU_ACCEPT); + if (ph_world_player(&world)->vitals.hp != + ph_world_entity_stats(&world, ph_world_player(&world)).max_hp || + ph_world_player(&world)->vitals.mp != 40) { + fprintf(stderr, "render smoke test failed: field Heal\n"); + result = 1; + } + world.entities[world.player_index].vitals.mp = + ph_world_entity_stats(&world, ph_world_player(&world)).max_mp; + context.menu = PH_MENU_SPELLBOOK; + ph_present_frame(renderer, &assets, &context); + spellbook_ui.mode = PH_SPELLBOOK_ACTIONS; + ph_present_frame(renderer, &assets, &context); + spellbook_ui.mode = PH_SPELLBOOK_EXAMINE; + ph_present_frame(renderer, &assets, &context); ph_shop_open(&shop_ui, ph_shop_defs[0].id); for (i = PH_SHOP_ROOT; i <= PH_SHOP_TALK; ++i) { shop_ui.mode = (PhShopMode)i; @@ -191,6 +219,25 @@ main(void) ph_present_frame(renderer, &assets, &context); world.entities[enemy_index].vitals.hp = ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp; + world.entities[world.player_index].vitals.hp -= 18; + battle.choice.command = PH_BATTLE_MAGICK; + ph_battle_select(&battle, &world); + battle.choice.spell = 1; + ph_battle_cast(&battle, &world); + if (battle.phase != PH_BATTLE_TARGET || + battle.choice.target_index != world.player_index) result = 1; + ph_battle_confirm_target(&battle, &world); + ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); + if (ph_world_player(&world)->vitals.hp != + ph_world_entity_stats(&world, ph_world_player(&world)).max_hp || + ph_world_player(&world)->vitals.mp != 40) { + fprintf(stderr, "render smoke test failed: combat Heal\n"); + result = 1; + } + world.entities[world.player_index].vitals.mp = + ph_world_entity_stats(&world, ph_world_player(&world)).max_mp; + ph_battle_begin(&battle, &world, enemy_index, spells); + ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1); battle.choice.command = PH_BATTLE_FIGHT; ph_battle_order(&battle, &world, order); if (order[0] != world.player_index || order[1] != enemy_index) { @@ -216,6 +263,13 @@ main(void) context.menu = PH_MENU_NONE; ph_present_frame(renderer, &assets, &context); ph_battle_cast(&battle, &world); + if (battle.phase != PH_BATTLE_TARGET || + battle.choice.target_index != enemy_index) { + fprintf(stderr, "render smoke test failed: offensive target default\n"); + result = 1; + } + ph_present_frame(renderer, &assets, &context); + ph_battle_confirm_target(&battle, &world); context.menu = PH_MENU_NONE; ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); ph_present_frame(renderer, &assets, &context); @@ -232,6 +286,7 @@ main(void) } battle.choice.command = PH_BATTLE_FIGHT; ph_battle_select(&battle, &world); + ph_battle_confirm_target(&battle, &world); context.menu = PH_MENU_NONE; ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); ph_present_frame(renderer, &assets, &context); diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -219,6 +219,7 @@ ph_logic_test(int open, int blocked) int order[PH_BATTLE_ORDER_COUNT]; int player_index; int enemy_index; + int ally_index; memset(tiles, blocked, sizeof(tiles)); tiles[ROW * area.width + LEFT_COL] = (unsigned char)open; @@ -276,6 +277,21 @@ ph_logic_test(int open, int blocked) world.encounter_index != enemy_index || ph_world_apply_accuracy_penalty(&world, enemy_index, 20, 3) < 0) return 1; + world.entities[player_index].vitals.mp = 20; + ph_battle_cast(&battle, &world); + if (battle.phase != PH_BATTLE_TARGET || + battle.choice.target_index != enemy_index) return 1; + ph_battle_cancel(&battle); + battle.choice.spell = 1; + ph_battle_cast(&battle, &world); + ally_index = ph_world_spawn_entity(&world, 1, player->pos, 0, 1); + if (battle.phase != PH_BATTLE_TARGET || + battle.choice.target_index != player_index || + ally_index < 0 || + ph_battle_target_step(&battle, &world, 1) != ally_index || + ph_battle_target_step(&battle, &world, 1) != player_index) return 1; + ph_battle_cancel(&battle); + world.entities[player_index].vitals.mp = 5; ph_battle_cancel(&battle); ph_battle_order(&battle, &world, order); if (battle.phase != PH_BATTLE_COMMAND || order[0] != player_index || @@ -340,6 +356,14 @@ ph_area_shop_test(void) if (!potion || !ether || world.area_id != PH_AREA_MEADOW || player->gold != 40 || world.initialized_area_count != 1) goto fail; + player->vitals.hp = 10; + if (ph_spellbook_use(&spells, &world, world.player_index, + PH_SPELL_FLASH) >= 0 || + ph_spellbook_use(&spells, &world, world.player_index, + PH_SPELL_HEAL) != 18 || player->vitals.hp != 28 || + player->vitals.mp != 40) goto fail; + player->vitals.hp = ph_world_entity_stats(&world, player).max_hp; + player->vitals.mp = ph_world_entity_stats(&world, player).max_mp; if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 || world.area_id != PH_AREA_STONEHOLLOW || world.initialized_area_count != 2) goto fail;