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