phantasia

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

commit 0a1b42c8e5d5306a911ca1c2a6696659550341e3
parent 60ea9097738a1caea17a654d1ab1c720d9c3b954
Author: beep <beep@wimdupont.com>
Date:   Thu, 30 Jul 2026 07:49:18 +0000

Separate presentation from game orchestration

Diffstat:
MMakefile | 21+++++++++++++++------
MREADME.adoc | 15++++++++++++---
Asrc/engine/battle.c | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/engine/battle.h | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/game/battle.c | 272-------------------------------------------------------------------------------
Dsrc/game/battle.h | 59-----------------------------------------------------------
Msrc/game/content.c | 9+++++++--
Msrc/game/content.h | 14+++-----------
Msrc/game/main.c | 1401++-----------------------------------------------------------------------------
Asrc/game/presentation.c | 1020+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/game/presentation.h | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/render.c | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/smoke.c | 7+++++--
13 files changed, 1799 insertions(+), 1721 deletions(-)

diff --git a/Makefile b/Makefile @@ -1,5 +1,6 @@ BIN = bin/phantasia SMOKE = bin/ph-smoke +RENDER_SMOKE = bin/ph-render-smoke MAPC = bin/ph-mapc OBJDIR = obj MAPDIR = $(OBJDIR)/maps @@ -15,10 +16,11 @@ CRITTERS_SRC = assets/vendor/16x16-animated-critters/16x16babies.png CRITTERS_BMP = $(OBJDIR)/assets/critters.bmp SRC = \ + src/engine/battle.c \ src/engine/world.c \ src/game/area.c \ - src/game/battle.c \ src/game/content.c \ + src/game/presentation.c \ src/game/main.c MAPC_SRC = src/tools/mapc.c src/game/content.c @@ -26,8 +28,9 @@ CONFIG = config.h OBJ = $(SRC:src/%.c=$(OBJDIR)/%.o) SMOKE_OBJ = $(OBJDIR)/tests/smoke.o -CORE_OBJ = $(filter-out $(OBJDIR)/game/main.o,$(OBJ)) -DEP = $(OBJ:.o=.d) $(SMOKE_OBJ:.o=.d) +RENDER_SMOKE_OBJ = $(OBJDIR)/tests/render.o +CORE_OBJ = $(filter-out $(OBJDIR)/game/main.o $(OBJDIR)/game/presentation.o,$(OBJ)) +DEP = $(OBJ:.o=.d) $(SMOKE_OBJ:.o=.d) $(RENDER_SMOKE_OBJ:.o=.d) SDL3_CFLAGS = $(shell pkg-config --cflags sdl3 2>/dev/null) SDL3_LDLIBS = $(shell pkg-config --libs sdl3 2>/dev/null) @@ -56,6 +59,12 @@ $(SMOKE): $(CORE_OBJ) $(SMOKE_OBJ) @mkdir -p $(dir $@) $(CC) $^ -o $@ -lm +$(RENDER_SMOKE): $(CORE_OBJ) $(OBJDIR)/game/presentation.o \ + $(RENDER_SMOKE_OBJ) $(MAPBIN) $(TILESET_BMP) $(HERO_BMP) $(ITEMS_BMP) $(CRITTERS_BMP) + @mkdir -p $(dir $@) + $(CC) $(CORE_OBJ) $(OBJDIR)/game/presentation.o $(RENDER_SMOKE_OBJ) \ + -o $@ $(LDLIBS) + $(MAPC): $(MAPC_SRC) $(CONFIG) @mkdir -p $(dir $@) $(CC) $(CPPFLAGS) $(CFLAGS) $(MAPC_SRC) -o $@ @@ -64,7 +73,7 @@ $(OBJDIR)/%.o: src/%.c @mkdir -p $(dir $@) $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@ -$(OBJ) $(SMOKE_OBJ): $(CONFIG) +$(OBJ) $(SMOKE_OBJ) $(RENDER_SMOKE_OBJ): $(CONFIG) $(CONFIG): cp config.def.h $@ @@ -92,8 +101,8 @@ $(CRITTERS_BMP): $(CRITTERS_SRC) smoke: $(MAPBIN) $(SMOKE) $(SMOKE) -render-smoke: $(BIN) - SDL_VIDEODRIVER=dummy $(BIN) --render-smoke-test +render-smoke: $(RENDER_SMOKE) + SDL_VIDEODRIVER=dummy $(RENDER_SMOKE) clean: rm -rf bin $(OBJDIR) diff --git a/README.adoc b/README.adoc @@ -17,10 +17,18 @@ interactions, and map markers instead of named content such as Mira or Stonehollow. Named IDs belong in the content catalog and content-specific tests; initialization, world logic, and presentation treat those IDs as opaque data. +The source tree enforces that boundary. `src/engine` contains reusable mechanics +and must never include `game/*`. `src/game` owns Phantasia content, area +composition, presentation, and application startup; it may depend on the engine. +Code belongs in the engine only when it can operate without named Phantasia +content or presentation knowledge. + Core world and CTB battle logic are independent of SDL. `src/game/main.c` -composes those systems with input and presentation; `src/game/battle.c` owns the -battle state machine. Headless world and battle coverage lives separately in -`src/tests/smoke.c`; production `main.c` contains no logic-test fixtures. +composes those systems with input and presentation; `src/engine/battle.c` owns +the battle state machine and receives the game's spell book as data. Headless +world and battle coverage lives in `src/tests/smoke.c`; SDL presentation coverage +lives in `src/tests/render.c` and builds as a separate executable. Production +`main.c` contains no test fixtures. `src/game/area.c` loads content-defined maps and portals while preserving the state of entities and ground items belonging to inactive areas. Marker content is instantiated once when an area is first entered; only the active tile map is @@ -142,6 +150,7 @@ Current tile legend across the meadow and Stonehollow town maps: * `,` = walkable town path * `[`, `^`, `]`, `{`, `}`, `(`, `_`, `)` = blocked building parts * `=` and `+` = walkable building floor or doorway +* `T` = visible portal icon produced from a portal marker Current content-marker legend: diff --git a/src/engine/battle.c b/src/engine/battle.c @@ -0,0 +1,272 @@ +#include "engine/battle.h" + +#include <stdio.h> +#include <string.h> + +#include "config.h" + +#if PH_BATTLE_CTB_BASE <= 0 || PH_BATTLE_RESOLVE_PERCENT < 0 || \ + PH_BATTLE_RESOLVE_PERCENT > 100 +#error "invalid CTB configuration" +#endif + +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 int +ph_battle_item(const PhWorld *world) +{ + const PhEntity *player = ph_world_player(world); + PhStats stats = ph_world_entity_stats(world, player); + int i; + + for (i = 0; i < world->item_def_count; ++i) + if (world->inventory[i] > 0 && player && + ((world->item_defs[i].use == PH_ITEM_USE_HEAL && + player->hp < stats.max_hp) || + (world->item_defs[i].use == PH_ITEM_USE_RESTORE_MP && + player->mp < stats.max_mp))) return world->item_defs[i].id; + return 0; +} + +const PhSpellDef * +ph_spell_def(const PhBattle *battle, int id) +{ + int i; + + for (i = 0; i < battle->spells.def_count; ++i) + if (battle->spells.defs[i].id == id) return &battle->spells.defs[i]; + return NULL; +} + +const PhSpellDef * +ph_battle_spell(const PhBattle *battle) +{ + if (battle->spell < 0 || battle->spell >= battle->spells.learned_count) + return NULL; + return ph_spell_def(battle, battle->spells.learned[battle->spell]); +} + +void +ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index, + PhSpellBook spells) +{ + const PhEntity *enemy = &world->entities[enemy_index]; + const PhEntityDef *def = ph_world_entity_def(world, enemy->type_id); + + memset(battle, 0, sizeof(*battle)); + battle->spells = spells; + 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; + battle->loot_item_id = def->loot_item_id; + battle->loot_amount = def->loot_amount; + snprintf(battle->message, sizeof(battle->message), "%s approaches!", def->name); + } +} + +void +ph_battle_select(PhBattle *battle, PhWorld *world) +{ + if (battle->phase != PH_BATTLE_COMMAND || !ph_world_player(world) || + battle->current_actor != world->player_index) return; + if (battle->command == PH_BATTLE_MAGICK) { + battle->spell = 0; + battle->phase = PH_BATTLE_MAGIC; + snprintf(battle->message, sizeof(battle->message), "Choose a spell."); + return; + } + if (battle->command == PH_BATTLE_ITEM && + !(battle->item_id = ph_battle_item(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->action_delay = ph_battle_actor_delay(world, world->player_index); +} + +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; +} + +void +ph_battle_cancel(PhBattle *battle) +{ + if (battle->phase == PH_BATTLE_MAGIC) battle->phase = PH_BATTLE_COMMAND; +} + +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); + 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 (spell && battle->phase == PH_BATTLE_MAGIC) + delay = delay * spell->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 +ph_battle_resolve(PhBattle *battle, PhWorld *world) +{ + const PhEntityDef *def; + const PhSpellDef *spell = NULL; + int damage; + + if (battle->phase == PH_BATTLE_PLAYER_ACTION) { + def = ph_world_entity_def(world, world->entities[battle->enemy_index].type_id); + if (battle->command == PH_BATTLE_FIGHT) { + damage = ph_world_physical_attack(world, world->player_index, + battle->enemy_index); + } else if (battle->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_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); + + damage = ph_world_use_inventory_item(world, world->player_index, + battle->item_id) < 0 ? -1 : 0; + snprintf(battle->message, sizeof(battle->message), "Used %s.", + item && item->name ? item->name : "item"); + return; + } + if (battle->command == PH_BATTLE_MAGICK && damage >= 0) + snprintf(battle->message, sizeof(battle->message), + "%s deals %d damage.", spell->name, 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); +} + +PhBattleResult +ph_battle_update(PhBattle *battle, PhWorld *world, float dt, int transition) +{ + if (transition) { + battle->timer -= dt; + if (battle->timer > 0.0f) return PH_BATTLE_CONTINUE; + battle->timer = 0.0f; + ph_battle_start_turn(battle, world, world->player_index); + return PH_BATTLE_READY; + } + if (battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC) + return PH_BATTLE_CONTINUE; + battle->timer -= dt; + if (!battle->resolved && battle->timer <= PH_BATTLE_ACTION_SECONDS * + PH_BATTLE_RESOLVE_PERCENT / 100.0f) { + ph_battle_resolve(battle, world); + battle->resolved = 1; + } + if (battle->timer > 0.0f) return PH_BATTLE_CONTINUE; + if (battle->phase == PH_BATTLE_PLAYER_ACTION && + world->entities[battle->enemy_index].hp <= 0) { + if (ph_world_finish_encounter(world, battle->enemy_index) == 0) + return PH_BATTLE_VICTORY; + return PH_BATTLE_CONTINUE; + } + if (battle->phase == PH_BATTLE_ENEMY_ACTION && + world->entities[world->player_index].hp <= 0) + return PH_BATTLE_DEFEAT; + ph_battle_next_turn(battle, world); + return PH_BATTLE_CONTINUE; +} + +float +ph_battle_lunge(const PhBattle *battle, PhBattlePhase phase) +{ + float progress; + + if (battle->phase != phase || PH_BATTLE_ACTION_SECONDS <= 0.0f) return 0.0f; + progress = 1.0f - battle->timer / PH_BATTLE_ACTION_SECONDS; + return (progress < 0.5f ? progress : 1.0f - progress) * + PH_BATTLE_LUNGE_DISTANCE; +} diff --git a/src/engine/battle.h b/src/engine/battle.h @@ -0,0 +1,78 @@ +#ifndef PH_ENGINE_BATTLE_H +#define PH_ENGINE_BATTLE_H + +#include "engine/world.h" + +enum { PH_BATTLE_ORDER_COUNT = 6 }; + +typedef enum { + PH_BATTLE_COMMAND, + PH_BATTLE_MAGIC, + PH_BATTLE_PLAYER_ACTION, + PH_BATTLE_ENEMY_ACTION, +} PhBattlePhase; + +typedef enum { + PH_BATTLE_CONTINUE, + PH_BATTLE_READY, + PH_BATTLE_VICTORY, + PH_BATTLE_DEFEAT, +} PhBattleResult; + +typedef enum { + PH_BATTLE_FIGHT, + PH_BATTLE_MAGICK, + PH_BATTLE_ITEM, + PH_BATTLE_COMMAND_COUNT, +} PhBattleCommand; + +typedef struct { + int id; + const char *name; + int mp_cost; + int power; + int delay_percent; + int accuracy_penalty; + int accuracy_turns; +} PhSpellDef; + +typedef struct { + const PhSpellDef *defs; + int def_count; + const int *learned; + int learned_count; +} PhSpellBook; + +typedef struct { + int enemy_index; + PhBattleCommand command; + int spell; + int item_id; + int resolved; + 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]; + PhSpellBook spells; +} PhBattle; + +void ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index, + PhSpellBook spells); +void ph_battle_select(PhBattle *battle, PhWorld *world); +void ph_battle_cast(PhBattle *battle, PhWorld *world); +void ph_battle_cancel(PhBattle *battle); +PhBattleResult ph_battle_update(PhBattle *battle, PhWorld *world, float dt, + int transition); +void ph_battle_order(const PhBattle *battle, const PhWorld *world, + int order[PH_BATTLE_ORDER_COUNT]); +const PhSpellDef *ph_spell_def(const PhBattle *battle, int id); +const PhSpellDef *ph_battle_spell(const PhBattle *battle); +float ph_battle_lunge(const PhBattle *battle, PhBattlePhase phase); + +#endif diff --git a/src/game/battle.c b/src/game/battle.c @@ -1,272 +0,0 @@ -#include "game/battle.h" - -#include <stdio.h> -#include <string.h> - -#include "config.h" - -#define LEN(a) (sizeof(a) / sizeof(0[a])) - -#if PH_BATTLE_CTB_BASE <= 0 || PH_BATTLE_RESOLVE_PERCENT < 0 || \ - PH_BATTLE_RESOLVE_PERCENT > 100 -#error "invalid CTB configuration" -#endif - -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 int -ph_battle_item(const PhWorld *world) -{ - const PhEntity *player = ph_world_player(world); - PhStats stats = ph_world_entity_stats(world, player); - int i; - - for (i = 0; i < world->item_def_count; ++i) - if (world->inventory[i] > 0 && player && - ((world->item_defs[i].use == PH_ITEM_USE_HEAL && - player->hp < stats.max_hp) || - (world->item_defs[i].use == PH_ITEM_USE_RESTORE_MP && - player->mp < stats.max_mp))) return world->item_defs[i].id; - return 0; -} - -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; -} - -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]); -} - -void -ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index) -{ - const PhEntity *enemy = &world->entities[enemy_index]; - const PhEntityDef *def = ph_world_entity_def(world, enemy->type_id); - - 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; - battle->loot_item_id = def->loot_item_id; - battle->loot_amount = def->loot_amount; - snprintf(battle->message, sizeof(battle->message), "%s approaches!", def->name); - } -} - -void -ph_battle_select(PhBattle *battle, PhWorld *world) -{ - if (battle->phase != PH_BATTLE_COMMAND || !ph_world_player(world) || - battle->current_actor != world->player_index) return; - if (battle->command == PH_BATTLE_MAGICK) { - battle->spell = 0; - battle->phase = PH_BATTLE_MAGIC; - snprintf(battle->message, sizeof(battle->message), "Choose a spell."); - return; - } - if (battle->command == PH_BATTLE_ITEM && - !(battle->item_id = ph_battle_item(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->action_delay = ph_battle_actor_delay(world, world->player_index); -} - -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; -} - -void -ph_battle_cancel(PhBattle *battle) -{ - if (battle->phase == PH_BATTLE_MAGIC) battle->phase = PH_BATTLE_COMMAND; -} - -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); - 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 (spell && battle->phase == PH_BATTLE_MAGIC) - delay = delay * spell->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 -ph_battle_resolve(PhBattle *battle, PhWorld *world) -{ - const PhEntityDef *def; - const PhSpellDef *spell = NULL; - int damage; - - if (battle->phase == PH_BATTLE_PLAYER_ACTION) { - def = ph_world_entity_def(world, world->entities[battle->enemy_index].type_id); - if (battle->command == PH_BATTLE_FIGHT) { - damage = ph_world_physical_attack(world, world->player_index, - battle->enemy_index); - } else if (battle->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_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); - - damage = ph_world_use_inventory_item(world, world->player_index, - battle->item_id) < 0 ? -1 : 0; - snprintf(battle->message, sizeof(battle->message), "Used %s.", - item && item->name ? item->name : "item"); - return; - } - if (battle->command == PH_BATTLE_MAGICK && damage >= 0) - snprintf(battle->message, sizeof(battle->message), - "%s deals %d damage.", spell->name, 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); -} - -PhBattleResult -ph_battle_update(PhBattle *battle, PhWorld *world, float dt, int transition) -{ - if (transition) { - battle->timer -= dt; - if (battle->timer > 0.0f) return PH_BATTLE_CONTINUE; - battle->timer = 0.0f; - ph_battle_start_turn(battle, world, world->player_index); - return PH_BATTLE_READY; - } - if (battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC) - return PH_BATTLE_CONTINUE; - battle->timer -= dt; - if (!battle->resolved && battle->timer <= PH_BATTLE_ACTION_SECONDS * - PH_BATTLE_RESOLVE_PERCENT / 100.0f) { - ph_battle_resolve(battle, world); - battle->resolved = 1; - } - if (battle->timer > 0.0f) return PH_BATTLE_CONTINUE; - if (battle->phase == PH_BATTLE_PLAYER_ACTION && - world->entities[battle->enemy_index].hp <= 0) { - if (ph_world_finish_encounter(world, battle->enemy_index) == 0) - return PH_BATTLE_VICTORY; - return PH_BATTLE_CONTINUE; - } - if (battle->phase == PH_BATTLE_ENEMY_ACTION && - world->entities[world->player_index].hp <= 0) - return PH_BATTLE_DEFEAT; - ph_battle_next_turn(battle, world); - return PH_BATTLE_CONTINUE; -} - -float -ph_battle_lunge(const PhBattle *battle, PhBattlePhase phase) -{ - float progress; - - if (battle->phase != phase || PH_BATTLE_ACTION_SECONDS <= 0.0f) return 0.0f; - progress = 1.0f - battle->timer / PH_BATTLE_ACTION_SECONDS; - return (progress < 0.5f ? progress : 1.0f - progress) * - PH_BATTLE_LUNGE_DISTANCE; -} diff --git a/src/game/battle.h b/src/game/battle.h @@ -1,59 +0,0 @@ -#ifndef PH_GAME_BATTLE_H -#define PH_GAME_BATTLE_H - -#include "game/content.h" - -enum { PH_BATTLE_ORDER_COUNT = 6 }; - -typedef enum { - PH_BATTLE_COMMAND, - PH_BATTLE_MAGIC, - PH_BATTLE_PLAYER_ACTION, - PH_BATTLE_ENEMY_ACTION, -} PhBattlePhase; - -typedef enum { - PH_BATTLE_CONTINUE, - PH_BATTLE_READY, - PH_BATTLE_VICTORY, - PH_BATTLE_DEFEAT, -} PhBattleResult; - -typedef enum { - PH_BATTLE_FIGHT, - PH_BATTLE_MAGICK, - PH_BATTLE_ITEM, - PH_BATTLE_COMMAND_COUNT, -} PhBattleCommand; - -typedef struct { - int enemy_index; - PhBattleCommand command; - int spell; - int item_id; - int resolved; - 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]; -} PhBattle; - -void ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index); -void ph_battle_select(PhBattle *battle, PhWorld *world); -void ph_battle_cast(PhBattle *battle, PhWorld *world); -void ph_battle_cancel(PhBattle *battle); -PhBattleResult ph_battle_update(PhBattle *battle, PhWorld *world, float dt, - int transition); -void ph_battle_order(const PhBattle *battle, const PhWorld *world, - int order[PH_BATTLE_ORDER_COUNT]); -const PhSpellDef *ph_spell_def(int id); -const PhSpellDef *ph_battle_spell(const PhBattle *battle); -float ph_battle_lunge(const PhBattle *battle, PhBattlePhase phase); - -#endif diff --git a/src/game/content.c b/src/game/content.c @@ -26,6 +26,7 @@ const PhTileDef ph_tile_defs[PH_TILE_DEF_COUNT] = { { '_', 1, PH_ASSET_TILESET, 10, 3 }, { ')', 1, PH_ASSET_TILESET, 11, 3 }, { '+', 0, PH_ASSET_TILESET, 10, 3 }, + { 'T', 0, PH_ASSET_TILESET, 6, 11 }, }; const PhSpellDef ph_spell_defs[PH_SPELL_DEF_COUNT] = { @@ -34,6 +35,10 @@ const PhSpellDef ph_spell_defs[PH_SPELL_DEF_COUNT] = { const int ph_player_spells[PH_PLAYER_SPELL_COUNT] = { PH_SPELL_FLASH }; +const PhSpellBook ph_player_spellbook = { + ph_spell_defs, PH_SPELL_DEF_COUNT, ph_player_spells, PH_PLAYER_SPELL_COUNT, +}; + const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT] = { { .id = PH_DEF_PLAYER, @@ -167,12 +172,12 @@ const PhMarkerDef ph_marker_defs[PH_MARKER_DEF_COUNT] = { PH_ITEM_RUSTED_SPEAR, .amount = 1 }, { PH_AREA_MEADOW, '2', '.', PH_MARKER_ITEM, PH_ITEM_HEALING_POTION, .amount = 1 }, - { PH_AREA_MEADOW, '>', '.', PH_MARKER_PORTAL, + { PH_AREA_MEADOW, '>', 'T', PH_MARKER_PORTAL, .destination_area_id = PH_AREA_STONEHOLLOW, .destination_symbol = 'e' }, { PH_AREA_MEADOW, 'e', '.', PH_MARKER_ARRIVAL, .content_id = 0 }, { PH_AREA_STONEHOLLOW, 'm', '=', PH_MARKER_ENTITY, PH_DEF_SHOPKEEPER, .amount = 0 }, - { PH_AREA_STONEHOLLOW, '<', ',', PH_MARKER_PORTAL, + { PH_AREA_STONEHOLLOW, '<', 'T', PH_MARKER_PORTAL, .destination_area_id = PH_AREA_MEADOW, .destination_symbol = 'e' }, { PH_AREA_STONEHOLLOW, 'e', ',', PH_MARKER_ARRIVAL, .content_id = 0 }, }; diff --git a/src/game/content.h b/src/game/content.h @@ -1,6 +1,7 @@ #ifndef PH_GAME_CONTENT_H #define PH_GAME_CONTENT_H +#include "engine/battle.h" #include "engine/world.h" typedef struct { @@ -56,16 +57,6 @@ typedef struct { int content_id; } PhInteractionDef; -typedef struct { - int id; - const char *name; - int mp_cost; - int power; - int delay_percent; - int accuracy_penalty; - int accuracy_turns; -} PhSpellDef; - enum { PH_ASSET_NONE, PH_ASSET_TILESET, @@ -112,7 +103,7 @@ enum { }; enum { - PH_TILE_DEF_COUNT = 13, + PH_TILE_DEF_COUNT = 14, PH_MARKER_DEF_COUNT = 9, }; @@ -123,6 +114,7 @@ extern const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT]; extern const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT]; extern const PhSpellDef ph_spell_defs[PH_SPELL_DEF_COUNT]; extern const int ph_player_spells[PH_PLAYER_SPELL_COUNT]; +extern const PhSpellBook ph_player_spellbook; extern const int ph_player_equipment[PH_EQUIP_COUNT]; extern const PhMarkerDef ph_marker_defs[PH_MARKER_DEF_COUNT]; extern const PhShopDef ph_shop_defs[PH_SHOP_COUNT]; diff --git a/src/game/main.c b/src/game/main.c @@ -1,12 +1,10 @@ -#include "engine/world.h" +#include "engine/battle.h" #include "game/area.h" -#include "game/battle.h" #include "game/content.h" +#include "game/presentation.h" -#include <math.h> #include <stdbool.h> #include <stdio.h> -#include <string.h> #if PH_USE_SDL #include <SDL3/SDL.h> @@ -20,1094 +18,30 @@ #error "config.h version does not match the engine; merge config.def.h" #else -#define LEN(a) (sizeof(a) / sizeof(0[a])) - -#if PH_USE_SDL -typedef struct { - SDL_Texture *textures[LEN(ph_asset_defs)]; -} PhAssets; - -typedef enum { - PH_MENU_NONE, - PH_MENU_CHARACTER, - PH_MENU_INVENTORY, - PH_MENU_SHOP, -} PhMenu; - -typedef enum { - PH_GAME_WORLD, - PH_GAME_TRANSITION, - PH_GAME_BATTLE, - PH_GAME_VICTORY, - PH_GAME_OVER, -} PhGameMode; - -typedef enum { - PH_INVENTORY_BROWSE, - PH_INVENTORY_ACTIONS, - PH_INVENTORY_EXAMINE, -} PhInventoryMode; - -typedef enum { - PH_INVENTORY_EQUIP, - PH_INVENTORY_USE, - PH_INVENTORY_DROP, - PH_INVENTORY_EXAMINE_ACTION, - PH_INVENTORY_ACTION_COUNT, -} PhInventoryAction; - -typedef struct { - int selected; - int option; - PhInventoryMode mode; - char status[64]; -} PhInventoryUi; - -typedef enum { - PH_SHOP_ROOT, - PH_SHOP_BUY, - PH_SHOP_SELL, - PH_SHOP_TALK, -} PhShopMode; - -typedef struct { - int shop_id; - int selected; - PhShopMode mode; - char status[64]; -} PhShopUi; - -typedef struct { - PhWorld *world; - PhInventoryUi *inventory; - PhShopUi *shop; - PhGameMode *mode; - PhBattle *battle; - PhMenu menu; -} PhGameContext; - -enum { PH_UI_FONT_W = 8 }; - -static float -ph_ui_right(float inset) -{ - return (float)PH_VIEW_W - inset; -} - -static float -ph_ui_bottom(float inset) -{ - return (float)PH_VIEW_H - inset; -} - -static float -ph_ui_center_text(const char *text) -{ - return ((float)PH_VIEW_W - (float)strlen(text) * PH_UI_FONT_W) * 0.5f; -} - -static float -ph_world_pixel(float position, float camera) -{ - return floorf(position + 0.5f) - floorf(camera + 0.5f); -} - -static float -ph_draw_wrapped_text(SDL_Renderer *renderer, float x, float y, - float width, float line_height, const char *text) -{ - char line[128]; - size_t columns = width > PH_UI_FONT_W ? (size_t)(width / PH_UI_FONT_W) : 1; - - while (text && *text) { - size_t remaining; - size_t take; - size_t i; - - while (*text == ' ') ++text; - remaining = strlen(text); - take = remaining < columns ? remaining : columns; - if (take < remaining) - for (i = take; i > 0; --i) - if (text[i] == ' ') { - take = i; - break; - } - if (take >= sizeof(line)) take = sizeof(line) - 1; - memcpy(line, text, take); - line[take] = '\0'; - SDL_RenderDebugText(renderer, x, y, line); - text += take; - y += line_height; - } - return y; -} -#endif - #if PH_USE_SDL -static SDL_Texture * -ph_load_sprite_sheet(SDL_Renderer *renderer, const char *path, - Uint8 key_r, Uint8 key_g, Uint8 key_b) -{ - SDL_Surface *surface; - SDL_Texture *texture; - - surface = SDL_LoadBMP(path); - if (!surface) { - fprintf(stderr, "SDL_LoadBMP failed for %s: %s\n", path, SDL_GetError()); - return NULL; - } - - SDL_SetSurfaceColorKey(surface, true, SDL_MapSurfaceRGB(surface, key_r, key_g, key_b)); - texture = SDL_CreateTextureFromSurface(renderer, surface); - SDL_DestroySurface(surface); - if (!texture) { - fprintf(stderr, "SDL_CreateTextureFromSurface failed: %s\n", SDL_GetError()); - } - return texture; -} - -static int -ph_load_assets(PhAssets *assets, SDL_Renderer *renderer) -{ - size_t i; - - memset(assets, 0, sizeof(*assets)); - for (i = 1; i < LEN(ph_asset_defs); ++i) { - if (!ph_asset_defs[i].path) continue; - assets->textures[i] = ph_load_sprite_sheet(renderer, ph_asset_defs[i].path, - ph_asset_defs[i].key_r, ph_asset_defs[i].key_g, ph_asset_defs[i].key_b); - if (!assets->textures[i]) { - while (i > 0) SDL_DestroyTexture(assets->textures[--i]); - return -1; - } - } - return assets->textures[PH_ASSET_TILESET] ? 0 : -1; -} - -static void -ph_destroy_assets(PhAssets *assets) -{ - size_t i; - - for (i = 1; i < LEN(assets->textures); ++i) - SDL_DestroyTexture(assets->textures[i]); - memset(assets, 0, sizeof(*assets)); -} - -static void -ph_draw_sprite(SDL_Renderer *renderer, SDL_Texture *texture, - int sprite_tile_x, int sprite_tile_y, SDL_FRect dst, SDL_FlipMode flip) -{ - SDL_FRect src = { - .x = (float)(sprite_tile_x * PH_TILE_SIZE), - .y = (float)(sprite_tile_y * PH_TILE_SIZE), - .w = (float)PH_TILE_SIZE, - .h = (float)PH_TILE_SIZE, - }; - - SDL_RenderTextureRotated(renderer, texture, &src, &dst, 0.0, NULL, flip); -} - -static void -ph_entity_sprite_frame(const PhEntityDef *def, const PhEntity *entity, - int *sprite_tile_x, int *sprite_tile_y, SDL_FlipMode *flip) -{ - int direction = entity->facing_y > 0 ? PH_SPRITE_DOWN : - entity->facing_y < 0 ? PH_SPRITE_UP : PH_SPRITE_SIDE; - int frame = ph_entity_animation_frame(def, entity); - - *sprite_tile_x = def->sprite_tiles[direction][frame][0]; - *sprite_tile_y = def->sprite_tiles[direction][frame][1]; - *flip = direction == PH_SPRITE_SIDE && entity->facing_x > 0 ? - SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE; -} - -static void -ph_draw_area(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *world) -{ - int tx; - int ty; - - for (ty = (int)(world->camera.pos.y / PH_TILE_SIZE); - ty < world->area.height && (float)(ty * PH_TILE_SIZE) < - world->camera.pos.y + world->camera.viewport_h; ++ty) { - for (tx = (int)(world->camera.pos.x / PH_TILE_SIZE); - tx < world->area.width && (float)(tx * PH_TILE_SIZE) < - world->camera.pos.x + world->camera.viewport_w; ++tx) { - const PhTileDef *def = ph_area_tile_def(&world->area, tx, ty); - SDL_Texture *texture; - SDL_FRect rect = { - .x = ph_world_pixel((float)(tx * PH_TILE_SIZE), world->camera.pos.x), - .y = ph_world_pixel((float)(ty * PH_TILE_SIZE), world->camera.pos.y), - .w = (float)PH_TILE_SIZE, - .h = (float)PH_TILE_SIZE, - }; - SDL_FRect src; - - if (!def || def->asset_id <= PH_ASSET_NONE || - (size_t)def->asset_id >= LEN(assets->textures)) continue; - texture = assets->textures[def->asset_id]; - if (!texture) continue; - src = (SDL_FRect){ - .x = (float)(def->sprite_tile_x * PH_TILE_SIZE), - .y = (float)(def->sprite_tile_y * PH_TILE_SIZE), - .w = (float)PH_TILE_SIZE, - .h = (float)PH_TILE_SIZE, - }; - - SDL_RenderTexture(renderer, texture, &src, &rect); - } - } -} - -static void -ph_draw_items(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *world) -{ - int i; - - for (i = 0; i < world->ground_item_count; ++i) { - const PhGroundItem *drop = &world->ground_items[i]; - const PhItemDef *def = ph_world_item_def(world, drop->item_id); - SDL_Texture *texture = NULL; - SDL_FRect rect; - - if (!drop->active || drop->area_id != world->area_id) { - continue; - } - - rect.x = ph_world_pixel(drop->pos.x, world->camera.pos.x) - 3.0f; - rect.y = ph_world_pixel(drop->pos.y, world->camera.pos.y) - 3.0f; - rect.w = 6.0f; - rect.h = 6.0f; - - if (def && def->asset_id > PH_ASSET_NONE && - (size_t)def->asset_id < LEN(assets->textures)) - texture = assets->textures[def->asset_id]; - if (texture && def->sprite_tile_x >= 0 && def->sprite_tile_y >= 0) { - rect.x = ph_world_pixel(drop->pos.x, world->camera.pos.x) - - PH_TILE_SIZE * 0.5f; - rect.y = ph_world_pixel(drop->pos.y, world->camera.pos.y) - - PH_TILE_SIZE * 0.5f; - rect.w = PH_TILE_SIZE; - rect.h = PH_TILE_SIZE; - ph_draw_sprite(renderer, texture, - def->sprite_tile_x, def->sprite_tile_y, rect, SDL_FLIP_NONE); - } else { - SDL_SetRenderDrawColor(renderer, PH_ITEM_COLOR); - SDL_RenderFillRect(renderer, &rect); - } - } -} - -static void -ph_draw_entities(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *world) -{ - int i; - - for (i = 0; i < world->entity_count; ++i) { - const PhEntity *entity = &world->entities[i]; - const PhEntityDef *def = ph_world_entity_def(world, entity->type_id); - SDL_Texture *texture = NULL; - SDL_FRect rect; - int sprite_tile_x; - int sprite_tile_y; - SDL_FlipMode flip; - - if (!entity->active || entity->area_id != world->area_id || !def) { - continue; - } - - if (def->asset_id > PH_ASSET_NONE && - (size_t)def->asset_id < LEN(assets->textures)) - texture = assets->textures[def->asset_id]; - ph_entity_sprite_frame(def, entity, &sprite_tile_x, &sprite_tile_y, &flip); - if (texture && sprite_tile_x >= 0 && sprite_tile_y >= 0) { - - rect.x = ph_world_pixel(entity->pos.x, world->camera.pos.x) - - PH_TILE_SIZE * 0.5f; - rect.y = ph_world_pixel(entity->pos.y, world->camera.pos.y) - - PH_TILE_SIZE * 0.75f; - rect.w = PH_TILE_SIZE; - rect.h = PH_TILE_SIZE; - - ph_draw_sprite(renderer, texture, sprite_tile_x, sprite_tile_y, rect, flip); - } else { - rect.x = ph_world_pixel(entity->pos.x, world->camera.pos.x) - 7.0f; - rect.y = ph_world_pixel(entity->pos.y, world->camera.pos.y) - 12.0f; - rect.w = 14.0f; - rect.h = 18.0f; - - if (def->kind == PH_ENTITY_MONSTER) { - SDL_SetRenderDrawColor(renderer, PH_MONSTER_COLOR); - } else { - SDL_SetRenderDrawColor(renderer, PH_NPC_COLOR); - } - SDL_RenderFillRect(renderer, &rect); - } - } -} - -static void -ph_append_bonus(char *text, size_t size, size_t *used, int value, const char *label) -{ - int written; - - if (!value || *used >= size) return; - written = snprintf(text + *used, size - *used, "%s%+d %s", - *used ? " " : "", value, label); - if (written > 0) *used += (size_t)written; -} - -static void -ph_format_bonuses(char *text, size_t size, PhStats bonuses) -{ - size_t used = 0; - - text[0] = '\0'; - ph_append_bonus(text, size, &used, bonuses.max_hp, "HP"); - ph_append_bonus(text, size, &used, bonuses.max_mp, "MP"); - ph_append_bonus(text, size, &used, bonuses.strength, "STR"); - ph_append_bonus(text, size, &used, bonuses.defense, "DEF"); - ph_append_bonus(text, size, &used, bonuses.magic, "MAG"); - ph_append_bonus(text, size, &used, bonuses.magic_defense, "MDEF"); - ph_append_bonus(text, size, &used, bonuses.agility, "AGI"); - ph_append_bonus(text, size, &used, bonuses.luck, "LCK"); - ph_append_bonus(text, size, &used, bonuses.evasion, "EVA"); - ph_append_bonus(text, size, &used, bonuses.accuracy, "ACC"); -} - -static void -ph_draw_menu(SDL_Renderer *renderer, const char *title, SDL_Scancode key) -{ - SDL_FRect panel = { 12.0f, 12.0f, PH_VIEW_W - 24.0f, PH_VIEW_H - 24.0f }; - char text[32]; - float x; - - SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR); - SDL_RenderFillRect(renderer, &panel); - SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR); - SDL_RenderRect(renderer, &panel); - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - SDL_RenderDebugText(renderer, 24.0f, 22.0f, title); - snprintf(text, sizeof(text), "CLOSE: %s", SDL_GetScancodeName(key)); - x = ph_ui_right(20.0f) - (float)strlen(text) * PH_UI_FONT_W; - if (x < 24.0f) x = 24.0f; - SDL_RenderDebugText(renderer, x, (float)(PH_VIEW_H - 22), text); -} - -static void -ph_draw_character_sheet(SDL_Renderer *renderer, const PhAssets *assets, - const PhWorld *world) -{ - static const char *slot_names[PH_EQUIP_COUNT] = { - [PH_EQUIP_WEAPON] = "WEAPON", - [PH_EQUIP_ARMOR] = "ARMOR", - [PH_EQUIP_CHARM] = "CHARM", - }; - const PhEntity *player = ph_world_player(world); - const PhEntityDef *def; - PhStats stats; - SDL_FRect avatar = { 32.0f, 46.0f, PH_TILE_SIZE * 3.0f, - PH_TILE_SIZE * 3.0f }; - char text[128]; - int slot; - - if (!player || !(def = ph_world_entity_def(world, player->type_id))) return; - stats = ph_world_entity_stats(world, player); - ph_draw_menu(renderer, "CHARACTER", PH_KEY_CHARACTER); - SDL_RenderDebugText(renderer, 104.0f, 38.0f, def->name); - - if (def->asset_id > PH_ASSET_NONE && (size_t)def->asset_id < LEN(assets->textures)) - ph_draw_sprite(renderer, assets->textures[def->asset_id], - def->sprite_tiles[PH_SPRITE_DOWN][0][0], - def->sprite_tiles[PH_SPRITE_DOWN][0][1], - avatar, SDL_FLIP_NONE); - - snprintf(text, sizeof(text), "HP %d / %d", player->hp, stats.max_hp); - SDL_RenderDebugText(renderer, 104.0f, 54.0f, text); - snprintf(text, sizeof(text), "MP %d / %d", player->mp, stats.max_mp); - SDL_RenderDebugText(renderer, 104.0f, 66.0f, text); - snprintf(text, sizeof(text), "STR %d", stats.strength); - SDL_RenderDebugText(renderer, 104.0f, 78.0f, text); - snprintf(text, sizeof(text), "DEF %d", stats.defense); - SDL_RenderDebugText(renderer, 104.0f, 90.0f, text); - snprintf(text, sizeof(text), "MAG %d", stats.magic); - SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 54.0f, text); - snprintf(text, sizeof(text), "MDEF %d", stats.magic_defense); - SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 66.0f, text); - snprintf(text, sizeof(text), "AGI %d", stats.agility); - SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 78.0f, text); - snprintf(text, sizeof(text), "LCK %d", stats.luck); - SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 90.0f, text); - snprintf(text, sizeof(text), "EVA %d", stats.evasion); - SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 102.0f, text); - snprintf(text, sizeof(text), "ACC %d", stats.accuracy); - SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 114.0f, text); - SDL_RenderDebugText(renderer, 24.0f, 128.0f, "EQUIPPED"); - - for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) { - const PhItemDef *item = ph_world_item_def(world, player->equipment[slot]); - float y = 144.0f + (float)(slot - PH_EQUIP_WEAPON) * 25.0f; - - snprintf(text, sizeof(text), "%s %s", slot_names[slot], item ? item->name : "-"); - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - SDL_RenderDebugText(renderer, 48.0f, y, text); - if (!item) continue; - if (item->asset_id > PH_ASSET_NONE && - (size_t)item->asset_id < LEN(assets->textures)) { - SDL_FRect icon = { 24.0f, y - 4.0f, PH_TILE_SIZE, PH_TILE_SIZE }; - ph_draw_sprite(renderer, assets->textures[item->asset_id], - item->sprite_tile_x, item->sprite_tile_y, icon, SDL_FLIP_NONE); - } - ph_format_bonuses(text, sizeof(text), item->bonuses); - SDL_SetRenderDrawColor(renderer, PH_MENU_BONUS_COLOR); - SDL_RenderDebugText(renderer, 48.0f, y + 10.0f, text); - } -} - -static int -ph_inventory_step(const PhWorld *world, int selected, int direction) -{ - int i = selected < 0 ? (direction > 0 ? -1 : 0) : selected; - int step; - - for (step = 0; step < world->item_def_count; ++step) { - i += direction; - if (i < 0) i = world->item_def_count - 1; - if (i >= world->item_def_count) i = 0; - if (world->inventory[i] > 0) return i; - } - return -1; -} - -static void -ph_inventory_open(PhInventoryUi *ui, const PhWorld *world) -{ - memset(ui, 0, sizeof(*ui)); - ui->selected = ph_inventory_step(world, -1, 1); -} - -static void -ph_inventory_reselect(PhInventoryUi *ui, const PhWorld *world) -{ - if (ui->selected >= 0 && world->inventory[ui->selected] > 0) return; - ui->selected = ph_inventory_step(world, ui->selected, 1); -} - -static void -ph_inventory_action(PhInventoryUi *ui, PhWorld *world, int action) -{ - const PhItemDef *item; - const char *name; - int result = -1; - - if (ui->selected < 0) return; - item = &world->item_defs[ui->selected]; - name = item->name ? item->name : "Item"; - if (action == PH_INVENTORY_EQUIP) - result = ph_world_equip_inventory_item(world, world->player_index, item->id); - else if (action == PH_INVENTORY_USE) - result = ph_world_use_inventory_item(world, world->player_index, item->id); - else if (action == PH_INVENTORY_DROP) - result = ph_world_drop_inventory_item(world, world->player_index, item->id); - else { - ui->mode = PH_INVENTORY_EXAMINE; - ui->status[0] = '\0'; - return; - } - - if (result < 0) { - snprintf(ui->status, sizeof(ui->status), "Cannot %s %s.", - action == PH_INVENTORY_EQUIP ? "equip" : - action == PH_INVENTORY_USE ? "use" : "drop", name); - return; - } - snprintf(ui->status, sizeof(ui->status), "%s %s.", - action == PH_INVENTORY_EQUIP ? "Equipped" : - action == PH_INVENTORY_USE ? "Used" : "Dropped", name); - ui->mode = PH_INVENTORY_BROWSE; - ph_inventory_reselect(ui, world); -} - -static int -ph_inventory_action_enabled(const PhItemDef *item, int action) -{ - if (action == PH_INVENTORY_EQUIP) return item->equip_slot != PH_EQUIP_NONE; - if (action == PH_INVENTORY_USE) return item->use != PH_ITEM_USE_NONE; - return 1; -} - -static void -ph_inventory_action_order(const PhItemDef *item, int actions[PH_INVENTORY_ACTION_COUNT]) -{ - if (item->equip_slot != PH_EQUIP_NONE) { - actions[0] = PH_INVENTORY_EQUIP; - actions[1] = PH_INVENTORY_USE; - } else if (item->use != PH_ITEM_USE_NONE) { - actions[0] = PH_INVENTORY_USE; - actions[1] = PH_INVENTORY_EQUIP; - } else { - actions[0] = PH_INVENTORY_DROP; - actions[1] = PH_INVENTORY_EXAMINE_ACTION; - } - if (actions[0] < PH_INVENTORY_DROP) { - actions[2] = PH_INVENTORY_DROP; - actions[3] = PH_INVENTORY_EXAMINE_ACTION; - } else { - actions[2] = PH_INVENTORY_EQUIP; - actions[3] = PH_INVENTORY_USE; - } -} - -static int -ph_inventory_action_step(const PhItemDef *item, int selected, int direction) -{ - int actions[PH_INVENTORY_ACTION_COUNT]; - int position = 0; - int step; - - ph_inventory_action_order(item, actions); - while (position < PH_INVENTORY_ACTION_COUNT && actions[position] != selected) ++position; - for (step = 0; step < PH_INVENTORY_ACTION_COUNT; ++step) { - position = (position + (direction < 0 ? - PH_INVENTORY_ACTION_COUNT - 1 : 1)) % PH_INVENTORY_ACTION_COUNT; - if (ph_inventory_action_enabled(item, actions[position])) return actions[position]; - } - return selected; -} - -static void -ph_draw_inventory_popup(SDL_Renderer *renderer, const PhWorld *world, - const PhInventoryUi *ui) -{ - static const char *action_names[] = { "EQUIP", "USE", "DROP", "EXAMINE" }; - const PhItemDef *item = &world->item_defs[ui->selected]; - int actions[PH_INVENTORY_ACTION_COUNT]; - SDL_FRect popup = ui->mode == PH_INVENTORY_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, 124.0f }; - char text[128]; - float x = ui->mode == PH_INVENTORY_EXAMINE ? 52.0f : ph_ui_right(152.0f); - int i; - - 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_INVENTORY_EXAMINE) { - SDL_RenderDebugText(renderer, x, 58.0f, - item->name ? item->name : "ITEM"); - SDL_RenderDebugText(renderer, x, 76.0f, - item->description ? item->description : "No description."); - snprintf(text, sizeof(text), "SELL %d G", item->sell_value); - SDL_RenderDebugText(renderer, x, 96.0f, text); - ph_format_bonuses(text, sizeof(text), item->bonuses); - if (item->use == PH_ITEM_USE_HEAL) - snprintf(text, sizeof(text), "HEALS %d HP", item->use_power); - else if (item->use == PH_ITEM_USE_RESTORE_MP) - snprintf(text, sizeof(text), "RESTORES %d MP", item->use_power); - if (text[0]) SDL_RenderDebugText(renderer, x, 112.0f, text); - SDL_RenderDebugText(renderer, x, 148.0f, "ENTER BACK"); - return; - } - - SDL_RenderDebugText(renderer, ph_ui_right(152.0f), 58.0f, "ITEM ACTION"); - ph_inventory_action_order(item, actions); - for (i = 0; i < PH_INVENTORY_ACTION_COUNT; ++i) { - int action = actions[i]; - - if (ph_inventory_action_enabled(item, action)) - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - else - SDL_SetRenderDrawColor(renderer, PH_MENU_DISABLED_COLOR); - snprintf(text, sizeof(text), "%c %s", - action == ui->option ? '>' : ' ', action_names[action]); - SDL_RenderDebugText(renderer, ph_ui_right(152.0f), - 78.0f + (float)i * 18.0f, text); - } -} - -static void -ph_draw_inventory(SDL_Renderer *renderer, const PhAssets *assets, - const PhWorld *world, const PhInventoryUi *ui) -{ - char text[128]; - int selected_rank = 0; - int total = 0; - int rank = 0; - int page; - int i; - - for (i = 0; i < world->item_def_count; ++i) - if (world->inventory[i] > 0) { - if (i == ui->selected) selected_rank = total; - ++total; - } - page = selected_rank / 5; - ph_draw_menu(renderer, "INVENTORY", PH_KEY_INVENTORY); - for (i = 0; i < world->item_def_count; ++i) { - const PhItemDef *item = &world->item_defs[i]; - float y; - - if (world->inventory[i] <= 0) continue; - if (rank < page * 5 || rank >= page * 5 + 5) { - ++rank; - continue; - } - y = 48.0f + (float)(rank++ - page * 5) * 27.0f; - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - SDL_RenderDebugText(renderer, 20.0f, y, i == ui->selected ? ">" : " "); - if (item->asset_id > PH_ASSET_NONE && - (size_t)item->asset_id < LEN(assets->textures)) { - SDL_FRect icon = { 32.0f, y - 4.0f, PH_TILE_SIZE, PH_TILE_SIZE }; - ph_draw_sprite(renderer, assets->textures[item->asset_id], - item->sprite_tile_x, item->sprite_tile_y, icon, SDL_FLIP_NONE); - } - snprintf(text, sizeof(text), "%s x%d", - item->name ? item->name : "Unknown", world->inventory[i]); - SDL_RenderDebugText(renderer, 56.0f, y, text); - ph_format_bonuses(text, sizeof(text), item->bonuses); - if (!text[0] && item->use == PH_ITEM_USE_HEAL) - snprintf(text, sizeof(text), "HEALS %d HP", item->use_power); - if (!text[0] && item->use == PH_ITEM_USE_RESTORE_MP) - snprintf(text, sizeof(text), "RESTORES %d MP", item->use_power); - if (!text[0]) snprintf(text, sizeof(text), "SELL %d G", item->sell_value); - SDL_SetRenderDrawColor(renderer, PH_MENU_BONUS_COLOR); - SDL_RenderDebugText(renderer, 56.0f, y + 10.0f, text); - } - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - if (!total) SDL_RenderDebugText(renderer, 24.0f, 48.0f, "EMPTY"); - if (total > 5) { - snprintf(text, sizeof(text), "PAGE %d/%d", page + 1, (total + 4) / 5); - 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); - snprintf(text, sizeof(text), "%s SELECT", - SDL_GetScancodeName(PH_KEY_MENU_ACCEPT)); - SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(24.0f), text); - if (ui->selected >= 0 && ui->mode != PH_INVENTORY_BROWSE) - ph_draw_inventory_popup(renderer, world, ui); -} - -static int -ph_inventory_key(PhInventoryUi *ui, PhWorld *world, 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; - - if (key == PH_KEY_MENU_CANCEL) { - if (ui->mode == PH_INVENTORY_EXAMINE) - ui->mode = PH_INVENTORY_ACTIONS; - else if (ui->mode == PH_INVENTORY_ACTIONS) - ui->mode = PH_INVENTORY_BROWSE; - else - return 1; - ui->status[0] = '\0'; - return 0; - } - if (key == PH_KEY_MENU_ACCEPT) { - if (ui->mode == PH_INVENTORY_BROWSE && ui->selected >= 0) { - int actions[PH_INVENTORY_ACTION_COUNT]; - - ph_inventory_action_order(&world->item_defs[ui->selected], actions); - ui->mode = PH_INVENTORY_ACTIONS; - ui->option = actions[0]; - ui->status[0] = '\0'; - } else if (ui->mode == PH_INVENTORY_ACTIONS) { - ph_inventory_action(ui, world, ui->option); - } else if (ui->mode == PH_INVENTORY_EXAMINE) { - ui->mode = PH_INVENTORY_ACTIONS; - } - return 0; - } - if ((!previous && !next) || ui->mode == PH_INVENTORY_EXAMINE) return 0; - if (ui->mode == PH_INVENTORY_ACTIONS) { - ui->option = ph_inventory_action_step(&world->item_defs[ui->selected], - ui->option, previous ? -1 : 1); - } else { - ui->selected = ph_inventory_step(world, ui->selected, previous ? -1 : 1); - ui->status[0] = '\0'; - } - return 0; -} - -static void -ph_shop_open(PhShopUi *ui, int shop_id) -{ - memset(ui, 0, sizeof(*ui)); - ui->shop_id = shop_id; -} - -static PhMenu -ph_interaction_open(PhShopUi *shop_ui, int interaction_id) -{ - const PhInteractionDef *interaction = ph_game_interaction_def(interaction_id); - - if (!interaction) return PH_MENU_NONE; - if (interaction->kind == PH_INTERACTION_SHOP && - ph_game_shop_def(interaction->content_id)) { - ph_shop_open(shop_ui, interaction->content_id); - return PH_MENU_SHOP; - } - return PH_MENU_NONE; -} - -static int -ph_shop_key(PhShopUi *ui, PhWorld *world, SDL_Scancode key) -{ - const PhShopDef *shop = ph_game_shop_def(ui->shop_id); - 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 (!shop) return 1; - if (key == PH_KEY_MENU_CANCEL) { - if (ui->mode == PH_SHOP_ROOT) return 1; - ui->mode = PH_SHOP_ROOT; - ui->selected = 0; - ui->status[0] = '\0'; - return 0; - } - if (ui->mode == PH_SHOP_TALK) { - if (key == PH_KEY_MENU_ACCEPT) ui->mode = PH_SHOP_ROOT; - return 0; - } - if (previous || next) { - int count = ui->mode == PH_SHOP_ROOT ? 3 : - ui->mode == PH_SHOP_BUY ? shop->item_count : world->item_def_count; - - if (ui->mode == PH_SHOP_SELL) { - ui->selected = ph_inventory_step(world, ui->selected, - previous ? -1 : 1); - } else if (count > 0) { - ui->selected = (ui->selected + (previous ? count - 1 : 1)) % count; - } - ui->status[0] = '\0'; - return 0; - } - if (key != PH_KEY_MENU_ACCEPT) return 0; - if (ui->mode == PH_SHOP_ROOT) { - ui->mode = (PhShopMode)(ui->selected + 1); - ui->selected = ui->mode == PH_SHOP_SELL ? - ph_inventory_step(world, -1, 1) : 0; - return 0; - } - if (ui->mode == PH_SHOP_BUY && ui->selected < shop->item_count) { - const PhItemDef *item = ph_world_item_def(world, shop->items[ui->selected]); - - if (!item || ph_world_buy_item(world, world->player_index, item->id) < 0) - snprintf(ui->status, sizeof(ui->status), "Not enough gold."); - else - snprintf(ui->status, sizeof(ui->status), "Bought %s.", item->name); - } else if (ui->mode == PH_SHOP_SELL && ui->selected >= 0) { - const PhItemDef *item = &world->item_defs[ui->selected]; - - if (ph_world_sell_item(world, world->player_index, item->id) < 0) - snprintf(ui->status, sizeof(ui->status), "Cannot sell that."); - else { - snprintf(ui->status, sizeof(ui->status), "Sold %s.", item->name); - if (world->inventory[ui->selected] <= 0) - ui->selected = ph_inventory_step(world, ui->selected, 1); - } - } - return 0; -} - static void -ph_draw_shop(SDL_Renderer *renderer, const PhWorld *world, const PhShopUi *ui) -{ - static const char *root_options[] = { "BUY", "SELL", "TALK" }; - const PhShopDef *shop = ph_game_shop_def(ui->shop_id); - const PhEntity *player = ph_world_player(world); - char text[128]; - int i; - - if (!shop || !player) return; - ph_draw_menu(renderer, shop->name, PH_KEY_MENU_CANCEL); - snprintf(text, sizeof(text), "GOLD %d", player->gold); - SDL_RenderDebugText(renderer, ph_ui_right(96.0f), 38.0f, text); - if (ui->mode == PH_SHOP_ROOT) { - for (i = 0; i < (int)LEN(root_options); ++i) { - snprintf(text, sizeof(text), "%c %s", ui->selected == i ? '>' : ' ', - root_options[i]); - SDL_RenderDebugText(renderer, 32.0f, 56.0f + (float)i * 20.0f, text); - } - SDL_RenderDebugText(renderer, 32.0f, 132.0f, "Welcome, traveler."); - } else if (ui->mode == PH_SHOP_BUY) { - SDL_RenderDebugText(renderer, 24.0f, 42.0f, "BUY"); - for (i = 0; i < shop->item_count; ++i) { - const PhItemDef *item = ph_world_item_def(world, shop->items[i]); - - if (!item) continue; - snprintf(text, sizeof(text), "%c %-18s %d G", - ui->selected == i ? '>' : ' ', item->name, item->buy_value); - SDL_RenderDebugText(renderer, 24.0f, 62.0f + (float)i * 20.0f, text); - } - } else if (ui->mode == PH_SHOP_SELL) { - int row = 0; - - SDL_RenderDebugText(renderer, 24.0f, 42.0f, "SELL"); - for (i = 0; i < world->item_def_count; ++i) { - const PhItemDef *item = &world->item_defs[i]; - - if (world->inventory[i] <= 0) continue; - snprintf(text, sizeof(text), "%c %-16s x%d %d G", - ui->selected == i ? '>' : ' ', item->name, world->inventory[i], - item->sell_value); - SDL_RenderDebugText(renderer, 24.0f, 62.0f + (float)row++ * 18.0f, text); - } - if (!row) SDL_RenderDebugText(renderer, 24.0f, 62.0f, "Nothing to sell."); - } else { - float y = 48.0f; - float width = ph_ui_right(24.0f) - 24.0f; - - for (i = 0; i < PH_SHOP_TALK_LINES; ++i) { - y = ph_draw_wrapped_text(renderer, 24.0f, y, width, 12.0f, - shop->talk[i]); - y += 6.0f; - } - SDL_RenderDebugText(renderer, 24.0f, y + 8.0f, "ENTER / ESC BACK"); - } - if (ui->status[0]) - SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(36.0f), ui->status); -} - -static void -ph_draw_battle_entity(SDL_Renderer *renderer, const PhAssets *assets, - const PhWorld *world, int entity_index, SDL_FRect rect) -{ - const PhEntity *entity = &world->entities[entity_index]; - const PhEntityDef *def = ph_world_entity_def(world, entity->type_id); - SDL_Texture *texture = NULL; - int x; - int y; - SDL_FlipMode flip; - - if (!def) return; - if (def->asset_id > PH_ASSET_NONE && (size_t)def->asset_id < LEN(assets->textures)) - texture = assets->textures[def->asset_id]; - if (def->kind == PH_ENTITY_PLAYER) { - x = def->sprite_tiles[PH_SPRITE_SIDE][0][0]; - y = def->sprite_tiles[PH_SPRITE_SIDE][0][1]; - flip = SDL_FLIP_NONE; - } else { - ph_entity_sprite_frame(def, entity, &x, &y, &flip); - } - if (texture) ph_draw_sprite(renderer, texture, x, y, rect, flip); - else { - if (def->kind == PH_ENTITY_MONSTER) - SDL_SetRenderDrawColor(renderer, PH_MONSTER_COLOR); - else - SDL_SetRenderDrawColor(renderer, PH_NPC_COLOR); - SDL_RenderFillRect(renderer, &rect); - } -} - -static void -ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, - const PhWorld *world, const PhBattle *battle) -{ - static const char *commands[PH_BATTLE_COMMAND_COUNT] = { - [PH_BATTLE_FIGHT] = "FIGHT", - [PH_BATTLE_MAGICK] = "MAGICK", - [PH_BATTLE_ITEM] = "ITEM", - }; - 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); - 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, PH_VIEW_H - 136.0f, PH_VIEW_W, 72.0f }; - SDL_FRect panel = { 8.0f, PH_VIEW_H - 64.0f, PH_VIEW_W - 16.0f, 56.0f }; - SDL_FRect spell_panel = { PH_VIEW_W * 0.5f - 88.0f, - PH_VIEW_H - 116.0f, 176.0f, - 24.0f + (float)LEN(ph_player_spells) * 12.0f }; - SDL_FRect enemy_rect = { 56.0f + ph_battle_lunge(battle, - PH_BATTLE_ENEMY_ACTION), PH_VIEW_H - 172.0f, - PH_TILE_SIZE * 3.0f, PH_TILE_SIZE * 3.0f }; - SDL_FRect player_rect = { PH_VIEW_W - 88.0f - ph_battle_lunge(battle, - PH_BATTLE_PLAYER_ACTION), PH_VIEW_H - 120.0f, - PH_TILE_SIZE * 3.0f, PH_TILE_SIZE * 3.0f }; - char text[96]; - int i; - - SDL_SetRenderDrawColor(renderer, PH_BATTLE_BG_COLOR); - SDL_RenderClear(renderer); - SDL_SetRenderDrawColor(renderer, PH_BATTLE_GROUND_COLOR); - 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); - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - SDL_RenderDebugText(renderer, 16.0f, 18.0f, - 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, ph_ui_right(96.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, ph_ui_right(80.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); - SDL_RenderRect(renderer, &panel); - 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->command == (PhBattleCommand)i ? '>' : ' ', - commands[i]); - SDL_RenderDebugText(renderer, 18.0f, - panel.y + 8.0f + (float)i * 14.0f, text); - } - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - SDL_RenderDebugText(renderer, 104.0f, panel.y + 8.0f, battle->message); - snprintf(text, sizeof(text), "HP %d/%d", player->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->mp, player_stats.max_mp); - SDL_RenderDebugText(renderer, ph_ui_right(80.0f), ph_ui_bottom(24.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, spell_panel.x + 8.0f, - spell_panel.y + 6.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%s", - battle->spell == i ? '>' : ' ', choice->name, choice->mp_cost, - choice->delay_percent < 100 ? " QUICK" : ""); - SDL_RenderDebugText(renderer, spell_panel.x + 8.0f, - spell_panel.y + 20.0f + (float)i * 12.0f, text); - } - } -} - -static void -ph_draw_battle_end(SDL_Renderer *renderer, const PhWorld *world, - const PhBattle *battle, PhGameMode game) -{ - const PhItemDef *loot = ph_world_item_def(world, battle->loot_item_id); - char text[96]; - - if (game == PH_GAME_VICTORY) - SDL_SetRenderDrawColor(renderer, PH_BATTLE_BG_COLOR); - else - SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR); - SDL_RenderClear(renderer); - SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); - if (game == PH_GAME_OVER) { - SDL_RenderDebugText(renderer, ph_ui_center_text("GAME OVER"), - PH_VIEW_H * 0.5f - 12.0f, "GAME OVER"); - return; - } - SDL_RenderDebugText(renderer, ph_ui_center_text("VICTORY!"), - PH_VIEW_H * 0.5f - 44.0f, "VICTORY!"); - snprintf(text, sizeof(text), "XP GAINED %d", battle->xp_reward); - SDL_RenderDebugText(renderer, ph_ui_center_text(text), - PH_VIEW_H * 0.5f - 14.0f, text); - if (loot && battle->loot_amount > 0) - snprintf(text, sizeof(text), "LOOT %s x%d", loot->name, battle->loot_amount); - else - snprintf(text, sizeof(text), "LOOT NONE"); - SDL_RenderDebugText(renderer, ph_ui_center_text(text), - PH_VIEW_H * 0.5f + 4.0f, text); - SDL_RenderDebugText(renderer, ph_ui_center_text("ENTER TO CONTINUE"), - PH_VIEW_H * 0.5f + 44.0f, "ENTER TO CONTINUE"); -} - -static void -ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, - PhGameContext *context, PhInput input, float dt) +ph_update_game(PhGameContext *context, PhInput input, float dt) { PhWorld *world = context->world; - const PhInventoryUi *inventory_ui = context->inventory; - const PhShopUi *shop_ui = context->shop; PhGameMode *game = context->mode; - PhBattle *battle = context->battle; - PhMenu menu = context->menu; - PhBattleResult battle_result = PH_BATTLE_CONTINUE; + PhBattleResult result = PH_BATTLE_CONTINUE; - if (*game == PH_GAME_WORLD && menu == PH_MENU_NONE) { + if (*game == PH_GAME_WORLD && context->menu == PH_MENU_NONE) { ph_world_tick(world, input, dt); if (ph_game_update_portal(world) < 0) fprintf(stderr, "failed to enter area\n"); if (world->encounter_index >= 0) { - ph_battle_begin(battle, world, world->encounter_index); + ph_battle_begin(context->battle, world, world->encounter_index, + ph_player_spellbook); *game = PH_GAME_TRANSITION; } } if (*game == PH_GAME_TRANSITION || *game == PH_GAME_BATTLE) - battle_result = ph_battle_update(battle, world, dt, + result = ph_battle_update(context->battle, world, dt, *game == PH_GAME_TRANSITION); - if (battle_result == PH_BATTLE_READY) *game = PH_GAME_BATTLE; - else if (battle_result == PH_BATTLE_VICTORY) *game = PH_GAME_VICTORY; - else if (battle_result == PH_BATTLE_DEFEAT) *game = PH_GAME_OVER; - if (*game == PH_GAME_BATTLE) { - ph_draw_battle(renderer, assets, world, battle); - SDL_RenderPresent(renderer); - return; - } - if (*game == PH_GAME_VICTORY || *game == PH_GAME_OVER) { - ph_draw_battle_end(renderer, world, battle, *game); - SDL_RenderPresent(renderer); - return; - } - - SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR); - SDL_RenderClear(renderer); - ph_draw_area(renderer, assets, world); - ph_draw_items(renderer, assets, world); - ph_draw_entities(renderer, assets, world); - if (menu == PH_MENU_CHARACTER) { - ph_draw_character_sheet(renderer, assets, world); - } else if (menu == PH_MENU_INVENTORY) { - ph_draw_inventory(renderer, assets, world, inventory_ui); - } else if (menu == PH_MENU_SHOP) { - ph_draw_shop(renderer, world, shop_ui); - } else if (world->notice_seconds > 0.0f && world->notice[0] != '\0') { - SDL_FRect notice_bg = { - .x = 8.0f, - .y = (float)(PH_VIEW_H - 28), - .w = (float)(PH_VIEW_W - 16), - .h = 20.0f, - }; - - SDL_SetRenderDrawColor(renderer, PH_NOTICE_COLOR); - SDL_RenderFillRect(renderer, &notice_bg); - SDL_SetRenderDrawColor(renderer, PH_NOTICE_TEXT_COLOR); - SDL_RenderDebugText(renderer, 14.0f, (float)(PH_VIEW_H - 22), world->notice); - } - if (*game == PH_GAME_TRANSITION) { - float progress = 1.0f - battle->timer / PH_BATTLE_TRANSITION_SECONDS; - float height = progress * PH_VIEW_H * 0.5f; - SDL_FRect top = { 0.0f, 0.0f, PH_VIEW_W, height }; - SDL_FRect bottom = { 0.0f, PH_VIEW_H - height, PH_VIEW_W, height }; - - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); - SDL_RenderFillRect(renderer, &top); - SDL_RenderFillRect(renderer, &bottom); - } - SDL_RenderPresent(renderer); + if (result == PH_BATTLE_READY) *game = PH_GAME_BATTLE; + else if (result == PH_BATTLE_VICTORY) *game = PH_GAME_VICTORY; + else if (result == PH_BATTLE_DEFEAT) *game = PH_GAME_OVER; } static PhInput @@ -1115,32 +49,19 @@ ph_read_input(const bool *keys) { PhInput input = { 0 }; - if (keys[PH_KEY_LEFT_1] || keys[PH_KEY_LEFT_2]) { - input.move_x -= 1; - } - if (keys[PH_KEY_RIGHT_1] || keys[PH_KEY_RIGHT_2]) { - input.move_x += 1; - } - if (keys[PH_KEY_UP_1] || keys[PH_KEY_UP_2]) { - input.move_y -= 1; - } - if (keys[PH_KEY_DOWN_1] || keys[PH_KEY_DOWN_2]) { - input.move_y += 1; - } - if (keys[PH_KEY_INTERACT_1] || keys[PH_KEY_INTERACT_2]) { - input.interact = 1; - } - + if (keys[PH_KEY_LEFT_1] || keys[PH_KEY_LEFT_2]) input.move_x -= 1; + if (keys[PH_KEY_RIGHT_1] || keys[PH_KEY_RIGHT_2]) input.move_x += 1; + if (keys[PH_KEY_UP_1] || keys[PH_KEY_UP_2]) input.move_y -= 1; + if (keys[PH_KEY_DOWN_1] || keys[PH_KEY_DOWN_2]) input.move_y += 1; + if (keys[PH_KEY_INTERACT_1] || keys[PH_KEY_INTERACT_2]) input.interact = 1; return input; } static int ph_create_window_renderer(SDL_Window **window, SDL_Renderer **renderer) { - *window = SDL_CreateWindow(PH_WINDOW_TITLE, - PH_VIEW_W * PH_SCALE, - PH_VIEW_H * PH_SCALE, - SDL_WINDOW_RESIZABLE); + *window = SDL_CreateWindow(PH_WINDOW_TITLE, PH_VIEW_W * PH_SCALE, + PH_VIEW_H * PH_SCALE, SDL_WINDOW_RESIZABLE); if (!*window) { fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); return -1; @@ -1148,255 +69,22 @@ ph_create_window_renderer(SDL_Window **window, SDL_Renderer **renderer) *renderer = SDL_CreateRenderer(*window, NULL); if (!*renderer) { - fprintf(stderr, "SDL_CreateRenderer default backend failed: %s\n", SDL_GetError()); + fprintf(stderr, "SDL_CreateRenderer default backend failed: %s\n", + SDL_GetError()); *renderer = SDL_CreateRenderer(*window, "software"); } if (!*renderer) { - fprintf(stderr, "SDL_CreateRenderer software fallback failed: %s\n", SDL_GetError()); + fprintf(stderr, "SDL_CreateRenderer software fallback failed: %s\n", + SDL_GetError()); SDL_DestroyWindow(*window); *window = NULL; return -1; } - return 0; } static int -ph_run_render_smoke_test(void) -{ - SDL_Surface *surface; - SDL_Renderer *renderer; - PhAssets assets; - PhWorld world; - PhInventoryUi inventory_ui; - PhShopUi shop_ui = { 0 }; - PhGameMode game = PH_GAME_WORLD; - PhBattle battle = { 0 }; - PhGameContext context = { &world, &inventory_ui, &shop_ui, &game, &battle, - PH_MENU_NONE }; - int i; - int enemy_index = -1; - int result = 0; - int order[PH_BATTLE_ORDER_COUNT]; - int first_item; - int second_item; - - if (!SDL_Init(SDL_INIT_VIDEO)) { - fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); - return 1; - } - - surface = SDL_CreateSurface(PH_VIEW_W, PH_VIEW_H, SDL_PIXELFORMAT_ARGB8888); - if (!surface) { - fprintf(stderr, "SDL_CreateSurface failed: %s\n", SDL_GetError()); - SDL_Quit(); - return 1; - } - - renderer = SDL_CreateSoftwareRenderer(surface); - if (!renderer) { - fprintf(stderr, "SDL_CreateSoftwareRenderer failed: %s\n", SDL_GetError()); - SDL_DestroySurface(surface); - SDL_Quit(); - return 1; - } - - if (ph_game_world_init(&world, (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) { - SDL_DestroyRenderer(renderer); - SDL_DestroySurface(surface); - SDL_Quit(); - return 1; - } - if (world.ground_item_count < 2) { - fprintf(stderr, "render smoke test failed: item setup\n"); - ph_area_free(&world.area); - SDL_DestroyRenderer(renderer); - SDL_DestroySurface(surface); - SDL_Quit(); - return 1; - } - first_item = world.ground_items[0].item_id; - second_item = world.ground_items[1].item_id; - - if (ph_load_assets(&assets, renderer) < 0) { - ph_area_free(&world.area); - SDL_DestroyRenderer(renderer); - SDL_DestroySurface(surface); - SDL_Quit(); - return 1; - } - - ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); - if (ph_world_item_amount(&world, first_item) != 0) { - fprintf(stderr, "render smoke test failed: adjacent item pickup\n"); - result = 1; - } - ph_world_tick(&world, (PhInput){ .move_x = 1 }, - (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f); - ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); - ph_world_tick(&world, (PhInput){ .move_y = 1 }, - (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f); - ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); - if (ph_world_item_amount(&world, first_item) != 1 || - ph_world_item_amount(&world, second_item) != 1) { - fprintf(stderr, "render smoke test failed: item pickup\n"); - result = 1; - } - ph_inventory_open(&inventory_ui, &world); - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT); - if (inventory_ui.option != PH_INVENTORY_EQUIP) result = 1; - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); - if (inventory_ui.option != PH_INVENTORY_DROP) result = 1; - inventory_ui.mode = PH_INVENTORY_BROWSE; - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT); - if (inventory_ui.option != PH_INVENTORY_USE) result = 1; - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); - if (inventory_ui.option != PH_INVENTORY_DROP) { - fprintf(stderr, "render smoke test failed: inventory actions\n"); - result = 1; - } - inventory_ui.mode = PH_INVENTORY_EXAMINE; - if (ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL) || - inventory_ui.mode != PH_INVENTORY_ACTIONS || - ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL) || - inventory_ui.mode != PH_INVENTORY_BROWSE || - !ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL)) { - fprintf(stderr, "render smoke test failed: inventory cancel\n"); - result = 1; - } - ph_inventory_open(&inventory_ui, &world); - for (i = 0; i < 10; ++i) { - inventory_ui.mode = i == 8 ? PH_INVENTORY_EXAMINE : - i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE; - context.menu = i == 7 ? PH_MENU_CHARACTER : - i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 1.0f / 60.0f); - } - 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; - shop_ui.selected = i == PH_SHOP_SELL ? - ph_inventory_step(&world, -1, 1) : 0; - context.menu = PH_MENU_SHOP; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f); - } - for (i = 0; i < world.entity_count; ++i) { - const PhEntityDef *def = ph_world_entity_def(&world, world.entities[i].type_id); - if (def && def->kind == PH_ENTITY_MONSTER) enemy_index = i; - } - if (enemy_index < 0) { - fprintf(stderr, "render smoke test failed: enemy setup\n"); - result = 1; - } else { - world.encounter_index = enemy_index; - game = PH_GAME_WORLD; - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f); - if (game != PH_GAME_TRANSITION) result = 1; - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, - PH_BATTLE_TRANSITION_SECONDS * 0.5f); - game = PH_GAME_BATTLE; - battle.phase = PH_BATTLE_ENEMY_ACTION; - battle.timer = 0.0f; - battle.resolved = 1; - world.entities[world.player_index].hp = 0; - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f); - 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_TRANSITION; - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, PH_BATTLE_TRANSITION_SECONDS); - world.entities[enemy_index].hp = - ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp; - battle.command = PH_BATTLE_FIGHT; - 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 = PH_BATTLE_MAGICK; - ph_battle_order(&battle, &world, order); - if (order[0] != world.player_index || order[1] != enemy_index) { - fprintf(stderr, "render smoke test failed: command CTB order\n"); - 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_battle_order(&battle, &world, order); - if (order[0] != world.player_index || order[1] != world.player_index) { - fprintf(stderr, "render smoke test failed: spell CTB order\n"); - result = 1; - } - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f); - ph_battle_cast(&battle, &world); - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS * 0.5f); - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS * 0.5f); - 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 = PH_BATTLE_FIGHT; - ph_battle_select(&battle, &world); - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS * 0.5f); - context.menu = PH_MENU_NONE; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS * 0.5f); - if (game != PH_GAME_VICTORY || ph_world_player(&world)->xp != battle.xp_reward || - ph_world_item_amount(&world, battle.loot_item_id) != 2) { - fprintf(stderr, "render smoke test failed: victory rewards\n"); - result = 1; - } - } - game = PH_GAME_WORLD; - for (i = 0; i < world.area.marker_count; ++i) { - const PhMarkerDef *def = ph_game_marker_def(world.area_id, - world.area.markers[i].symbol); - - if (!def || def->kind != PH_MARKER_PORTAL) continue; - world.entities[world.player_index].pos = (PhVec2){ - PH_TILE_CENTER(world.area.markers[i].tile_x), - PH_TILE_CENTER(world.area.markers[i].tile_y) }; - break; - } - if (i == world.area.marker_count || ph_game_update_portal(&world) != 1) { - fprintf(stderr, "render smoke test failed: town portal\n"); - result = 1; - } - ph_shop_open(&shop_ui, ph_shop_defs[0].id); - shop_ui.mode = PH_SHOP_TALK; - context.menu = PH_MENU_SHOP; - ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f); - - if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) { - fprintf(stderr, "SDL_SaveBMP failed: %s\n", SDL_GetError()); - result = 1; - } - - ph_destroy_assets(&assets); - ph_area_free(&world.area); - SDL_DestroyRenderer(renderer); - SDL_DestroySurface(surface); - SDL_Quit(); - return result; -} - -static int -ph_run_game(int frame_limit) +ph_run_game(void) { SDL_Window *window; SDL_Renderer *renderer; @@ -1409,7 +97,6 @@ ph_run_game(int frame_limit) PhGameContext context = { &world, &inventory_ui, &shop_ui, &game, &battle, PH_MENU_NONE }; Uint64 last_ticks; - int frame_count = 0; int running = 1; PhMenu menu = PH_MENU_NONE; @@ -1417,18 +104,15 @@ ph_run_game(int frame_limit) fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); return 1; } - if (ph_game_world_init(&world, (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) { SDL_Quit(); return 1; } - if (ph_create_window_renderer(&window, &renderer) < 0) { ph_area_free(&world.area); SDL_Quit(); return 1; } - if (ph_load_assets(&assets, renderer) < 0) { SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); @@ -1437,11 +121,8 @@ ph_run_game(int frame_limit) return 1; } - SDL_SetRenderLogicalPresentation(renderer, - PH_VIEW_W, - PH_VIEW_H, + SDL_SetRenderLogicalPresentation(renderer, PH_VIEW_W, PH_VIEW_H, SDL_LOGICAL_PRESENTATION_INTEGER_SCALE); - last_ticks = SDL_GetTicks(); while (running) { SDL_Event event; @@ -1462,10 +143,10 @@ ph_run_game(int frame_limit) key == PH_KEY_MENU_RIGHT_1 || key == PH_KEY_MENU_RIGHT_2; if (battle.phase == PH_BATTLE_MAGIC) { - if (previous || next) + if ((previous || next) && battle.spells.learned_count > 0) battle.spell = (battle.spell + (previous ? - (int)LEN(ph_player_spells) - 1 : 1)) % - (int)LEN(ph_player_spells); + battle.spells.learned_count - 1 : 1)) % + battle.spells.learned_count; else if (key == PH_KEY_MENU_ACCEPT) ph_battle_cast(&battle, &world); else if (key == PH_KEY_MENU_CANCEL) @@ -1494,9 +175,8 @@ ph_run_game(int frame_limit) game == PH_GAME_WORLD && (menu == PH_MENU_NONE || menu == PH_MENU_INVENTORY) && event.key.scancode == PH_KEY_INVENTORY) { - if (menu == PH_MENU_INVENTORY) { - menu = PH_MENU_NONE; - } else { + if (menu == PH_MENU_INVENTORY) menu = PH_MENU_NONE; + else { menu = PH_MENU_INVENTORY; ph_inventory_open(&inventory_ui, &world); } @@ -1513,23 +193,17 @@ ph_run_game(int frame_limit) now_ticks = SDL_GetTicks(); dt = (float)(now_ticks - last_ticks) / 1000.0f; - if (dt > PH_MAX_FRAME_TIME) { - dt = PH_MAX_FRAME_TIME; - } + if (dt > PH_MAX_FRAME_TIME) dt = PH_MAX_FRAME_TIME; last_ticks = now_ticks; - input = ph_read_input(SDL_GetKeyboardState(NULL)); context.menu = menu; - ph_render_frame(renderer, &assets, &context, input, dt); + ph_update_game(&context, input, dt); + ph_present_frame(renderer, &assets, &context); if (game == PH_GAME_WORLD && menu == PH_MENU_NONE && world.interaction_id > 0) { menu = ph_interaction_open(&shop_ui, world.interaction_id); world.interaction_id = 0; } - ++frame_count; - if (frame_limit > 0 && frame_count >= frame_limit) { - running = 0; - } SDL_Delay(PH_FRAME_DELAY_MS); } @@ -1543,19 +217,14 @@ ph_run_game(int frame_limit) #endif int -main(int argc, char **argv) +main(void) { #if PH_USE_SDL - if (argc > 1 && strcmp(argv[1], "--render-smoke-test") == 0) { - return ph_run_render_smoke_test(); - } - return ph_run_game(0); + return ph_run_game(); #else - (void)argc; - (void)argv; fprintf(stderr, "SDL3 not available in this build; run `make smoke`\n"); return 1; #endif } -#endif /* PH_CONFIG_VERSION */ +#endif diff --git a/src/game/presentation.c b/src/game/presentation.c @@ -0,0 +1,1020 @@ +#include "game/presentation.h" + +#include "game/area.h" + +#include <math.h> +#include <stdio.h> +#include <string.h> + +#include "config.h" + +#define LEN(a) (sizeof(a) / sizeof(0[a])) + +#if PH_USE_SDL +enum { PH_UI_FONT_W = 8 }; + +static float +ph_ui_right(float inset) +{ + return (float)PH_VIEW_W - inset; +} + +static float +ph_ui_bottom(float inset) +{ + return (float)PH_VIEW_H - inset; +} + +static float +ph_ui_center_text(const char *text) +{ + return ((float)PH_VIEW_W - (float)strlen(text) * PH_UI_FONT_W) * 0.5f; +} + +static float +ph_world_pixel(float position, float camera) +{ + return floorf(position + 0.5f) - floorf(camera + 0.5f); +} + +static float +ph_draw_wrapped_text(SDL_Renderer *renderer, float x, float y, + float width, float line_height, const char *text) +{ + char line[128]; + size_t columns = width > PH_UI_FONT_W ? (size_t)(width / PH_UI_FONT_W) : 1; + + while (text && *text) { + size_t remaining; + size_t take; + size_t i; + + while (*text == ' ') ++text; + remaining = strlen(text); + take = remaining < columns ? remaining : columns; + if (take < remaining) + for (i = take; i > 0; --i) + if (text[i] == ' ') { + take = i; + break; + } + if (take >= sizeof(line)) take = sizeof(line) - 1; + memcpy(line, text, take); + line[take] = '\0'; + SDL_RenderDebugText(renderer, x, y, line); + text += take; + y += line_height; + } + return y; +} +#endif + +#if PH_USE_SDL +static SDL_Texture * +ph_load_sprite_sheet(SDL_Renderer *renderer, const char *path, + Uint8 key_r, Uint8 key_g, Uint8 key_b) +{ + SDL_Surface *surface; + SDL_Texture *texture; + + surface = SDL_LoadBMP(path); + if (!surface) { + fprintf(stderr, "SDL_LoadBMP failed for %s: %s\n", path, SDL_GetError()); + return NULL; + } + + SDL_SetSurfaceColorKey(surface, true, SDL_MapSurfaceRGB(surface, key_r, key_g, key_b)); + texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_DestroySurface(surface); + if (!texture) { + fprintf(stderr, "SDL_CreateTextureFromSurface failed: %s\n", SDL_GetError()); + } + return texture; +} + +int +ph_load_assets(PhAssets *assets, SDL_Renderer *renderer) +{ + size_t i; + + memset(assets, 0, sizeof(*assets)); + for (i = 1; i < LEN(ph_asset_defs); ++i) { + if (!ph_asset_defs[i].path) continue; + assets->textures[i] = ph_load_sprite_sheet(renderer, ph_asset_defs[i].path, + ph_asset_defs[i].key_r, ph_asset_defs[i].key_g, ph_asset_defs[i].key_b); + if (!assets->textures[i]) { + while (i > 0) SDL_DestroyTexture(assets->textures[--i]); + return -1; + } + } + return assets->textures[PH_ASSET_TILESET] ? 0 : -1; +} + +void +ph_destroy_assets(PhAssets *assets) +{ + size_t i; + + for (i = 1; i < LEN(assets->textures); ++i) + SDL_DestroyTexture(assets->textures[i]); + memset(assets, 0, sizeof(*assets)); +} + +static void +ph_draw_sprite(SDL_Renderer *renderer, SDL_Texture *texture, + int sprite_tile_x, int sprite_tile_y, SDL_FRect dst, SDL_FlipMode flip) +{ + SDL_FRect src = { + .x = (float)(sprite_tile_x * PH_TILE_SIZE), + .y = (float)(sprite_tile_y * PH_TILE_SIZE), + .w = (float)PH_TILE_SIZE, + .h = (float)PH_TILE_SIZE, + }; + + SDL_RenderTextureRotated(renderer, texture, &src, &dst, 0.0, NULL, flip); +} + +static void +ph_entity_sprite_frame(const PhEntityDef *def, const PhEntity *entity, + int *sprite_tile_x, int *sprite_tile_y, SDL_FlipMode *flip) +{ + int direction = entity->facing_y > 0 ? PH_SPRITE_DOWN : + entity->facing_y < 0 ? PH_SPRITE_UP : PH_SPRITE_SIDE; + int frame = ph_entity_animation_frame(def, entity); + + *sprite_tile_x = def->sprite_tiles[direction][frame][0]; + *sprite_tile_y = def->sprite_tiles[direction][frame][1]; + *flip = direction == PH_SPRITE_SIDE && entity->facing_x > 0 ? + SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE; +} + +static void +ph_draw_area(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *world) +{ + int tx; + int ty; + + for (ty = (int)(world->camera.pos.y / PH_TILE_SIZE); + ty < world->area.height && (float)(ty * PH_TILE_SIZE) < + world->camera.pos.y + world->camera.viewport_h; ++ty) { + for (tx = (int)(world->camera.pos.x / PH_TILE_SIZE); + tx < world->area.width && (float)(tx * PH_TILE_SIZE) < + world->camera.pos.x + world->camera.viewport_w; ++tx) { + const PhTileDef *def = ph_area_tile_def(&world->area, tx, ty); + SDL_Texture *texture; + SDL_FRect rect = { + .x = ph_world_pixel((float)(tx * PH_TILE_SIZE), world->camera.pos.x), + .y = ph_world_pixel((float)(ty * PH_TILE_SIZE), world->camera.pos.y), + .w = (float)PH_TILE_SIZE, + .h = (float)PH_TILE_SIZE, + }; + SDL_FRect src; + + if (!def || def->asset_id <= PH_ASSET_NONE || + (size_t)def->asset_id >= LEN(assets->textures)) continue; + texture = assets->textures[def->asset_id]; + if (!texture) continue; + src = (SDL_FRect){ + .x = (float)(def->sprite_tile_x * PH_TILE_SIZE), + .y = (float)(def->sprite_tile_y * PH_TILE_SIZE), + .w = (float)PH_TILE_SIZE, + .h = (float)PH_TILE_SIZE, + }; + + SDL_RenderTexture(renderer, texture, &src, &rect); + } + } +} + +static void +ph_draw_items(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *world) +{ + int i; + + for (i = 0; i < world->ground_item_count; ++i) { + const PhGroundItem *drop = &world->ground_items[i]; + const PhItemDef *def = ph_world_item_def(world, drop->item_id); + SDL_Texture *texture = NULL; + SDL_FRect rect; + + if (!drop->active || drop->area_id != world->area_id) { + continue; + } + + rect.x = ph_world_pixel(drop->pos.x, world->camera.pos.x) - 3.0f; + rect.y = ph_world_pixel(drop->pos.y, world->camera.pos.y) - 3.0f; + rect.w = 6.0f; + rect.h = 6.0f; + + if (def && def->asset_id > PH_ASSET_NONE && + (size_t)def->asset_id < LEN(assets->textures)) + texture = assets->textures[def->asset_id]; + if (texture && def->sprite_tile_x >= 0 && def->sprite_tile_y >= 0) { + rect.x = ph_world_pixel(drop->pos.x, world->camera.pos.x) - + PH_TILE_SIZE * 0.5f; + rect.y = ph_world_pixel(drop->pos.y, world->camera.pos.y) - + PH_TILE_SIZE * 0.5f; + rect.w = PH_TILE_SIZE; + rect.h = PH_TILE_SIZE; + ph_draw_sprite(renderer, texture, + def->sprite_tile_x, def->sprite_tile_y, rect, SDL_FLIP_NONE); + } else { + SDL_SetRenderDrawColor(renderer, PH_ITEM_COLOR); + SDL_RenderFillRect(renderer, &rect); + } + } +} + +static void +ph_draw_entities(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *world) +{ + int i; + + for (i = 0; i < world->entity_count; ++i) { + const PhEntity *entity = &world->entities[i]; + const PhEntityDef *def = ph_world_entity_def(world, entity->type_id); + SDL_Texture *texture = NULL; + SDL_FRect rect; + int sprite_tile_x; + int sprite_tile_y; + SDL_FlipMode flip; + + if (!entity->active || entity->area_id != world->area_id || !def) { + continue; + } + + if (def->asset_id > PH_ASSET_NONE && + (size_t)def->asset_id < LEN(assets->textures)) + texture = assets->textures[def->asset_id]; + ph_entity_sprite_frame(def, entity, &sprite_tile_x, &sprite_tile_y, &flip); + if (texture && sprite_tile_x >= 0 && sprite_tile_y >= 0) { + + rect.x = ph_world_pixel(entity->pos.x, world->camera.pos.x) - + PH_TILE_SIZE * 0.5f; + rect.y = ph_world_pixel(entity->pos.y, world->camera.pos.y) - + PH_TILE_SIZE * 0.75f; + rect.w = PH_TILE_SIZE; + rect.h = PH_TILE_SIZE; + + ph_draw_sprite(renderer, texture, sprite_tile_x, sprite_tile_y, rect, flip); + } else { + rect.x = ph_world_pixel(entity->pos.x, world->camera.pos.x) - 7.0f; + rect.y = ph_world_pixel(entity->pos.y, world->camera.pos.y) - 12.0f; + rect.w = 14.0f; + rect.h = 18.0f; + + if (def->kind == PH_ENTITY_MONSTER) { + SDL_SetRenderDrawColor(renderer, PH_MONSTER_COLOR); + } else { + SDL_SetRenderDrawColor(renderer, PH_NPC_COLOR); + } + SDL_RenderFillRect(renderer, &rect); + } + } +} + +static void +ph_append_bonus(char *text, size_t size, size_t *used, int value, const char *label) +{ + int written; + + if (!value || *used >= size) return; + written = snprintf(text + *used, size - *used, "%s%+d %s", + *used ? " " : "", value, label); + if (written > 0) *used += (size_t)written; +} + +static void +ph_format_bonuses(char *text, size_t size, PhStats bonuses) +{ + size_t used = 0; + + text[0] = '\0'; + ph_append_bonus(text, size, &used, bonuses.max_hp, "HP"); + ph_append_bonus(text, size, &used, bonuses.max_mp, "MP"); + ph_append_bonus(text, size, &used, bonuses.strength, "STR"); + ph_append_bonus(text, size, &used, bonuses.defense, "DEF"); + ph_append_bonus(text, size, &used, bonuses.magic, "MAG"); + ph_append_bonus(text, size, &used, bonuses.magic_defense, "MDEF"); + ph_append_bonus(text, size, &used, bonuses.agility, "AGI"); + ph_append_bonus(text, size, &used, bonuses.luck, "LCK"); + ph_append_bonus(text, size, &used, bonuses.evasion, "EVA"); + ph_append_bonus(text, size, &used, bonuses.accuracy, "ACC"); +} + +static void +ph_draw_menu(SDL_Renderer *renderer, const char *title, SDL_Scancode key) +{ + SDL_FRect panel = { 12.0f, 12.0f, PH_VIEW_W - 24.0f, PH_VIEW_H - 24.0f }; + char text[32]; + float x; + + SDL_SetRenderDrawColor(renderer, PH_MENU_COLOR); + SDL_RenderFillRect(renderer, &panel); + SDL_SetRenderDrawColor(renderer, PH_MENU_BORDER_COLOR); + SDL_RenderRect(renderer, &panel); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, 24.0f, 22.0f, title); + snprintf(text, sizeof(text), "CLOSE: %s", SDL_GetScancodeName(key)); + x = ph_ui_right(20.0f) - (float)strlen(text) * PH_UI_FONT_W; + if (x < 24.0f) x = 24.0f; + SDL_RenderDebugText(renderer, x, (float)(PH_VIEW_H - 22), text); +} + +static void +ph_draw_character_sheet(SDL_Renderer *renderer, const PhAssets *assets, + const PhWorld *world) +{ + static const char *slot_names[PH_EQUIP_COUNT] = { + [PH_EQUIP_WEAPON] = "WEAPON", + [PH_EQUIP_ARMOR] = "ARMOR", + [PH_EQUIP_CHARM] = "CHARM", + }; + const PhEntity *player = ph_world_player(world); + const PhEntityDef *def; + PhStats stats; + SDL_FRect avatar = { 32.0f, 46.0f, PH_TILE_SIZE * 3.0f, + PH_TILE_SIZE * 3.0f }; + char text[128]; + int slot; + + if (!player || !(def = ph_world_entity_def(world, player->type_id))) return; + stats = ph_world_entity_stats(world, player); + ph_draw_menu(renderer, "CHARACTER", PH_KEY_CHARACTER); + SDL_RenderDebugText(renderer, 104.0f, 38.0f, def->name); + + if (def->asset_id > PH_ASSET_NONE && (size_t)def->asset_id < LEN(assets->textures)) + ph_draw_sprite(renderer, assets->textures[def->asset_id], + def->sprite_tiles[PH_SPRITE_DOWN][0][0], + def->sprite_tiles[PH_SPRITE_DOWN][0][1], + avatar, SDL_FLIP_NONE); + + snprintf(text, sizeof(text), "HP %d / %d", player->hp, stats.max_hp); + SDL_RenderDebugText(renderer, 104.0f, 54.0f, text); + snprintf(text, sizeof(text), "MP %d / %d", player->mp, stats.max_mp); + SDL_RenderDebugText(renderer, 104.0f, 66.0f, text); + snprintf(text, sizeof(text), "STR %d", stats.strength); + SDL_RenderDebugText(renderer, 104.0f, 78.0f, text); + snprintf(text, sizeof(text), "DEF %d", stats.defense); + SDL_RenderDebugText(renderer, 104.0f, 90.0f, text); + snprintf(text, sizeof(text), "MAG %d", stats.magic); + SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 54.0f, text); + snprintf(text, sizeof(text), "MDEF %d", stats.magic_defense); + SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 66.0f, text); + snprintf(text, sizeof(text), "AGI %d", stats.agility); + SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 78.0f, text); + snprintf(text, sizeof(text), "LCK %d", stats.luck); + SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 90.0f, text); + snprintf(text, sizeof(text), "EVA %d", stats.evasion); + SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 102.0f, text); + snprintf(text, sizeof(text), "ACC %d", stats.accuracy); + SDL_RenderDebugText(renderer, ph_ui_right(88.0f), 114.0f, text); + SDL_RenderDebugText(renderer, 24.0f, 128.0f, "EQUIPPED"); + + for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) { + const PhItemDef *item = ph_world_item_def(world, player->equipment[slot]); + float y = 144.0f + (float)(slot - PH_EQUIP_WEAPON) * 25.0f; + + snprintf(text, sizeof(text), "%s %s", slot_names[slot], item ? item->name : "-"); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, 48.0f, y, text); + if (!item) continue; + if (item->asset_id > PH_ASSET_NONE && + (size_t)item->asset_id < LEN(assets->textures)) { + SDL_FRect icon = { 24.0f, y - 4.0f, PH_TILE_SIZE, PH_TILE_SIZE }; + ph_draw_sprite(renderer, assets->textures[item->asset_id], + item->sprite_tile_x, item->sprite_tile_y, icon, SDL_FLIP_NONE); + } + ph_format_bonuses(text, sizeof(text), item->bonuses); + SDL_SetRenderDrawColor(renderer, PH_MENU_BONUS_COLOR); + SDL_RenderDebugText(renderer, 48.0f, y + 10.0f, text); + } +} + +int +ph_inventory_step(const PhWorld *world, int selected, int direction) +{ + int i = selected < 0 ? (direction > 0 ? -1 : 0) : selected; + int step; + + for (step = 0; step < world->item_def_count; ++step) { + i += direction; + if (i < 0) i = world->item_def_count - 1; + if (i >= world->item_def_count) i = 0; + if (world->inventory[i] > 0) return i; + } + return -1; +} + +void +ph_inventory_open(PhInventoryUi *ui, const PhWorld *world) +{ + memset(ui, 0, sizeof(*ui)); + ui->selected = ph_inventory_step(world, -1, 1); +} + +static void +ph_inventory_reselect(PhInventoryUi *ui, const PhWorld *world) +{ + if (ui->selected >= 0 && world->inventory[ui->selected] > 0) return; + ui->selected = ph_inventory_step(world, ui->selected, 1); +} + +static void +ph_inventory_action(PhInventoryUi *ui, PhWorld *world, int action) +{ + const PhItemDef *item; + const char *name; + int result = -1; + + if (ui->selected < 0) return; + item = &world->item_defs[ui->selected]; + name = item->name ? item->name : "Item"; + if (action == PH_INVENTORY_EQUIP) + result = ph_world_equip_inventory_item(world, world->player_index, item->id); + else if (action == PH_INVENTORY_USE) + result = ph_world_use_inventory_item(world, world->player_index, item->id); + else if (action == PH_INVENTORY_DROP) + result = ph_world_drop_inventory_item(world, world->player_index, item->id); + else { + ui->mode = PH_INVENTORY_EXAMINE; + ui->status[0] = '\0'; + return; + } + + if (result < 0) { + snprintf(ui->status, sizeof(ui->status), "Cannot %s %s.", + action == PH_INVENTORY_EQUIP ? "equip" : + action == PH_INVENTORY_USE ? "use" : "drop", name); + return; + } + snprintf(ui->status, sizeof(ui->status), "%s %s.", + action == PH_INVENTORY_EQUIP ? "Equipped" : + action == PH_INVENTORY_USE ? "Used" : "Dropped", name); + ui->mode = PH_INVENTORY_BROWSE; + ph_inventory_reselect(ui, world); +} + +static int +ph_inventory_action_enabled(const PhItemDef *item, int action) +{ + if (action == PH_INVENTORY_EQUIP) return item->equip_slot != PH_EQUIP_NONE; + if (action == PH_INVENTORY_USE) return item->use != PH_ITEM_USE_NONE; + return 1; +} + +static void +ph_inventory_action_order(const PhItemDef *item, int actions[PH_INVENTORY_ACTION_COUNT]) +{ + if (item->equip_slot != PH_EQUIP_NONE) { + actions[0] = PH_INVENTORY_EQUIP; + actions[1] = PH_INVENTORY_USE; + } else if (item->use != PH_ITEM_USE_NONE) { + actions[0] = PH_INVENTORY_USE; + actions[1] = PH_INVENTORY_EQUIP; + } else { + actions[0] = PH_INVENTORY_DROP; + actions[1] = PH_INVENTORY_EXAMINE_ACTION; + } + if (actions[0] < PH_INVENTORY_DROP) { + actions[2] = PH_INVENTORY_DROP; + actions[3] = PH_INVENTORY_EXAMINE_ACTION; + } else { + actions[2] = PH_INVENTORY_EQUIP; + actions[3] = PH_INVENTORY_USE; + } +} + +static int +ph_inventory_action_step(const PhItemDef *item, int selected, int direction) +{ + int actions[PH_INVENTORY_ACTION_COUNT]; + int position = 0; + int step; + + ph_inventory_action_order(item, actions); + while (position < PH_INVENTORY_ACTION_COUNT && actions[position] != selected) ++position; + for (step = 0; step < PH_INVENTORY_ACTION_COUNT; ++step) { + position = (position + (direction < 0 ? + PH_INVENTORY_ACTION_COUNT - 1 : 1)) % PH_INVENTORY_ACTION_COUNT; + if (ph_inventory_action_enabled(item, actions[position])) return actions[position]; + } + return selected; +} + +static void +ph_draw_inventory_popup(SDL_Renderer *renderer, const PhWorld *world, + const PhInventoryUi *ui) +{ + static const char *action_names[] = { "EQUIP", "USE", "DROP", "EXAMINE" }; + const PhItemDef *item = &world->item_defs[ui->selected]; + int actions[PH_INVENTORY_ACTION_COUNT]; + SDL_FRect popup = ui->mode == PH_INVENTORY_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, 124.0f }; + char text[128]; + float x = ui->mode == PH_INVENTORY_EXAMINE ? 52.0f : ph_ui_right(152.0f); + int i; + + 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_INVENTORY_EXAMINE) { + SDL_RenderDebugText(renderer, x, 58.0f, + item->name ? item->name : "ITEM"); + SDL_RenderDebugText(renderer, x, 76.0f, + item->description ? item->description : "No description."); + snprintf(text, sizeof(text), "SELL %d G", item->sell_value); + SDL_RenderDebugText(renderer, x, 96.0f, text); + ph_format_bonuses(text, sizeof(text), item->bonuses); + if (item->use == PH_ITEM_USE_HEAL) + snprintf(text, sizeof(text), "HEALS %d HP", item->use_power); + else if (item->use == PH_ITEM_USE_RESTORE_MP) + snprintf(text, sizeof(text), "RESTORES %d MP", item->use_power); + if (text[0]) SDL_RenderDebugText(renderer, x, 112.0f, text); + SDL_RenderDebugText(renderer, x, 148.0f, "ENTER BACK"); + return; + } + + SDL_RenderDebugText(renderer, ph_ui_right(152.0f), 58.0f, "ITEM ACTION"); + ph_inventory_action_order(item, actions); + for (i = 0; i < PH_INVENTORY_ACTION_COUNT; ++i) { + int action = actions[i]; + + if (ph_inventory_action_enabled(item, action)) + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + else + SDL_SetRenderDrawColor(renderer, PH_MENU_DISABLED_COLOR); + snprintf(text, sizeof(text), "%c %s", + action == ui->option ? '>' : ' ', action_names[action]); + SDL_RenderDebugText(renderer, ph_ui_right(152.0f), + 78.0f + (float)i * 18.0f, text); + } +} + +static void +ph_draw_inventory(SDL_Renderer *renderer, const PhAssets *assets, + const PhWorld *world, const PhInventoryUi *ui) +{ + char text[128]; + int selected_rank = 0; + int total = 0; + int rank = 0; + int page; + int i; + + for (i = 0; i < world->item_def_count; ++i) + if (world->inventory[i] > 0) { + if (i == ui->selected) selected_rank = total; + ++total; + } + page = selected_rank / 5; + ph_draw_menu(renderer, "INVENTORY", PH_KEY_INVENTORY); + for (i = 0; i < world->item_def_count; ++i) { + const PhItemDef *item = &world->item_defs[i]; + float y; + + if (world->inventory[i] <= 0) continue; + if (rank < page * 5 || rank >= page * 5 + 5) { + ++rank; + continue; + } + y = 48.0f + (float)(rank++ - page * 5) * 27.0f; + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, 20.0f, y, i == ui->selected ? ">" : " "); + if (item->asset_id > PH_ASSET_NONE && + (size_t)item->asset_id < LEN(assets->textures)) { + SDL_FRect icon = { 32.0f, y - 4.0f, PH_TILE_SIZE, PH_TILE_SIZE }; + ph_draw_sprite(renderer, assets->textures[item->asset_id], + item->sprite_tile_x, item->sprite_tile_y, icon, SDL_FLIP_NONE); + } + snprintf(text, sizeof(text), "%s x%d", + item->name ? item->name : "Unknown", world->inventory[i]); + SDL_RenderDebugText(renderer, 56.0f, y, text); + ph_format_bonuses(text, sizeof(text), item->bonuses); + if (!text[0] && item->use == PH_ITEM_USE_HEAL) + snprintf(text, sizeof(text), "HEALS %d HP", item->use_power); + if (!text[0] && item->use == PH_ITEM_USE_RESTORE_MP) + snprintf(text, sizeof(text), "RESTORES %d MP", item->use_power); + if (!text[0]) snprintf(text, sizeof(text), "SELL %d G", item->sell_value); + SDL_SetRenderDrawColor(renderer, PH_MENU_BONUS_COLOR); + SDL_RenderDebugText(renderer, 56.0f, y + 10.0f, text); + } + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + if (!total) SDL_RenderDebugText(renderer, 24.0f, 48.0f, "EMPTY"); + if (total > 5) { + snprintf(text, sizeof(text), "PAGE %d/%d", page + 1, (total + 4) / 5); + 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); + snprintf(text, sizeof(text), "%s SELECT", + SDL_GetScancodeName(PH_KEY_MENU_ACCEPT)); + SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(24.0f), text); + if (ui->selected >= 0 && ui->mode != PH_INVENTORY_BROWSE) + ph_draw_inventory_popup(renderer, world, ui); +} + +int +ph_inventory_key(PhInventoryUi *ui, PhWorld *world, 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; + + if (key == PH_KEY_MENU_CANCEL) { + if (ui->mode == PH_INVENTORY_EXAMINE) + ui->mode = PH_INVENTORY_ACTIONS; + else if (ui->mode == PH_INVENTORY_ACTIONS) + ui->mode = PH_INVENTORY_BROWSE; + else + return 1; + ui->status[0] = '\0'; + return 0; + } + if (key == PH_KEY_MENU_ACCEPT) { + if (ui->mode == PH_INVENTORY_BROWSE && ui->selected >= 0) { + int actions[PH_INVENTORY_ACTION_COUNT]; + + ph_inventory_action_order(&world->item_defs[ui->selected], actions); + ui->mode = PH_INVENTORY_ACTIONS; + ui->option = actions[0]; + ui->status[0] = '\0'; + } else if (ui->mode == PH_INVENTORY_ACTIONS) { + ph_inventory_action(ui, world, ui->option); + } else if (ui->mode == PH_INVENTORY_EXAMINE) { + ui->mode = PH_INVENTORY_ACTIONS; + } + return 0; + } + if ((!previous && !next) || ui->mode == PH_INVENTORY_EXAMINE) return 0; + if (ui->mode == PH_INVENTORY_ACTIONS) { + ui->option = ph_inventory_action_step(&world->item_defs[ui->selected], + ui->option, previous ? -1 : 1); + } else { + ui->selected = ph_inventory_step(world, ui->selected, previous ? -1 : 1); + ui->status[0] = '\0'; + } + return 0; +} + +void +ph_shop_open(PhShopUi *ui, int shop_id) +{ + memset(ui, 0, sizeof(*ui)); + ui->shop_id = shop_id; +} + +PhMenu +ph_interaction_open(PhShopUi *shop_ui, int interaction_id) +{ + const PhInteractionDef *interaction = ph_game_interaction_def(interaction_id); + + if (!interaction) return PH_MENU_NONE; + if (interaction->kind == PH_INTERACTION_SHOP && + ph_game_shop_def(interaction->content_id)) { + ph_shop_open(shop_ui, interaction->content_id); + return PH_MENU_SHOP; + } + return PH_MENU_NONE; +} + +int +ph_shop_key(PhShopUi *ui, PhWorld *world, SDL_Scancode key) +{ + const PhShopDef *shop = ph_game_shop_def(ui->shop_id); + 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 (!shop) return 1; + if (key == PH_KEY_MENU_CANCEL) { + if (ui->mode == PH_SHOP_ROOT) return 1; + ui->mode = PH_SHOP_ROOT; + ui->selected = 0; + ui->status[0] = '\0'; + return 0; + } + if (ui->mode == PH_SHOP_TALK) { + if (key == PH_KEY_MENU_ACCEPT) ui->mode = PH_SHOP_ROOT; + return 0; + } + if (previous || next) { + int count = ui->mode == PH_SHOP_ROOT ? 3 : + ui->mode == PH_SHOP_BUY ? shop->item_count : world->item_def_count; + + if (ui->mode == PH_SHOP_SELL) { + ui->selected = ph_inventory_step(world, ui->selected, + previous ? -1 : 1); + } else if (count > 0) { + ui->selected = (ui->selected + (previous ? count - 1 : 1)) % count; + } + ui->status[0] = '\0'; + return 0; + } + if (key != PH_KEY_MENU_ACCEPT) return 0; + if (ui->mode == PH_SHOP_ROOT) { + ui->mode = (PhShopMode)(ui->selected + 1); + ui->selected = ui->mode == PH_SHOP_SELL ? + ph_inventory_step(world, -1, 1) : 0; + return 0; + } + if (ui->mode == PH_SHOP_BUY && ui->selected < shop->item_count) { + const PhItemDef *item = ph_world_item_def(world, shop->items[ui->selected]); + + if (!item || ph_world_buy_item(world, world->player_index, item->id) < 0) + snprintf(ui->status, sizeof(ui->status), "Not enough gold."); + else + snprintf(ui->status, sizeof(ui->status), "Bought %s.", item->name); + } else if (ui->mode == PH_SHOP_SELL && ui->selected >= 0) { + const PhItemDef *item = &world->item_defs[ui->selected]; + + if (ph_world_sell_item(world, world->player_index, item->id) < 0) + snprintf(ui->status, sizeof(ui->status), "Cannot sell that."); + else { + snprintf(ui->status, sizeof(ui->status), "Sold %s.", item->name); + if (world->inventory[ui->selected] <= 0) + ui->selected = ph_inventory_step(world, ui->selected, 1); + } + } + return 0; +} + +static void +ph_draw_shop(SDL_Renderer *renderer, const PhWorld *world, const PhShopUi *ui) +{ + static const char *root_options[] = { "BUY", "SELL", "TALK" }; + const PhShopDef *shop = ph_game_shop_def(ui->shop_id); + const PhEntity *player = ph_world_player(world); + char text[128]; + int i; + + if (!shop || !player) return; + ph_draw_menu(renderer, shop->name, PH_KEY_MENU_CANCEL); + snprintf(text, sizeof(text), "GOLD %d", player->gold); + SDL_RenderDebugText(renderer, ph_ui_right(96.0f), 38.0f, text); + if (ui->mode == PH_SHOP_ROOT) { + for (i = 0; i < (int)LEN(root_options); ++i) { + snprintf(text, sizeof(text), "%c %s", ui->selected == i ? '>' : ' ', + root_options[i]); + SDL_RenderDebugText(renderer, 32.0f, 56.0f + (float)i * 20.0f, text); + } + SDL_RenderDebugText(renderer, 32.0f, 132.0f, "Welcome, traveler."); + } else if (ui->mode == PH_SHOP_BUY) { + SDL_RenderDebugText(renderer, 24.0f, 42.0f, "BUY"); + for (i = 0; i < shop->item_count; ++i) { + const PhItemDef *item = ph_world_item_def(world, shop->items[i]); + + if (!item) continue; + snprintf(text, sizeof(text), "%c %-18s %d G", + ui->selected == i ? '>' : ' ', item->name, item->buy_value); + SDL_RenderDebugText(renderer, 24.0f, 62.0f + (float)i * 20.0f, text); + } + } else if (ui->mode == PH_SHOP_SELL) { + int row = 0; + + SDL_RenderDebugText(renderer, 24.0f, 42.0f, "SELL"); + for (i = 0; i < world->item_def_count; ++i) { + const PhItemDef *item = &world->item_defs[i]; + + if (world->inventory[i] <= 0) continue; + snprintf(text, sizeof(text), "%c %-16s x%d %d G", + ui->selected == i ? '>' : ' ', item->name, world->inventory[i], + item->sell_value); + SDL_RenderDebugText(renderer, 24.0f, 62.0f + (float)row++ * 18.0f, text); + } + if (!row) SDL_RenderDebugText(renderer, 24.0f, 62.0f, "Nothing to sell."); + } else { + float y = 48.0f; + float width = ph_ui_right(24.0f) - 24.0f; + + for (i = 0; i < PH_SHOP_TALK_LINES; ++i) { + y = ph_draw_wrapped_text(renderer, 24.0f, y, width, 12.0f, + shop->talk[i]); + y += 6.0f; + } + SDL_RenderDebugText(renderer, 24.0f, y + 8.0f, "ENTER / ESC BACK"); + } + if (ui->status[0]) + SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(36.0f), ui->status); +} + +static void +ph_draw_battle_entity(SDL_Renderer *renderer, const PhAssets *assets, + const PhWorld *world, int entity_index, SDL_FRect rect) +{ + const PhEntity *entity = &world->entities[entity_index]; + const PhEntityDef *def = ph_world_entity_def(world, entity->type_id); + SDL_Texture *texture = NULL; + int x; + int y; + SDL_FlipMode flip; + + if (!def) return; + if (def->asset_id > PH_ASSET_NONE && (size_t)def->asset_id < LEN(assets->textures)) + texture = assets->textures[def->asset_id]; + if (def->kind == PH_ENTITY_PLAYER) { + x = def->sprite_tiles[PH_SPRITE_SIDE][0][0]; + y = def->sprite_tiles[PH_SPRITE_SIDE][0][1]; + flip = SDL_FLIP_NONE; + } else { + ph_entity_sprite_frame(def, entity, &x, &y, &flip); + } + if (texture) ph_draw_sprite(renderer, texture, x, y, rect, flip); + else { + if (def->kind == PH_ENTITY_MONSTER) + SDL_SetRenderDrawColor(renderer, PH_MONSTER_COLOR); + else + SDL_SetRenderDrawColor(renderer, PH_NPC_COLOR); + SDL_RenderFillRect(renderer, &rect); + } +} + +static void +ph_draw_battle(SDL_Renderer *renderer, const PhAssets *assets, + const PhWorld *world, const PhBattle *battle) +{ + static const char *commands[PH_BATTLE_COMMAND_COUNT] = { + [PH_BATTLE_FIGHT] = "FIGHT", + [PH_BATTLE_MAGICK] = "MAGICK", + [PH_BATTLE_ITEM] = "ITEM", + }; + 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); + 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, PH_VIEW_H - 136.0f, PH_VIEW_W, 72.0f }; + SDL_FRect panel = { 8.0f, PH_VIEW_H - 64.0f, PH_VIEW_W - 16.0f, 56.0f }; + SDL_FRect spell_panel = { PH_VIEW_W * 0.5f - 88.0f, + PH_VIEW_H - 116.0f, 176.0f, + 24.0f + (float)battle->spells.learned_count * 12.0f }; + SDL_FRect enemy_rect = { 56.0f + ph_battle_lunge(battle, + PH_BATTLE_ENEMY_ACTION), PH_VIEW_H - 172.0f, + PH_TILE_SIZE * 3.0f, PH_TILE_SIZE * 3.0f }; + SDL_FRect player_rect = { PH_VIEW_W - 88.0f - ph_battle_lunge(battle, + PH_BATTLE_PLAYER_ACTION), PH_VIEW_H - 120.0f, + PH_TILE_SIZE * 3.0f, PH_TILE_SIZE * 3.0f }; + char text[96]; + int i; + + SDL_SetRenderDrawColor(renderer, PH_BATTLE_BG_COLOR); + SDL_RenderClear(renderer); + SDL_SetRenderDrawColor(renderer, PH_BATTLE_GROUND_COLOR); + 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); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, 16.0f, 18.0f, + 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, ph_ui_right(96.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, ph_ui_right(80.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); + SDL_RenderRect(renderer, &panel); + 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->command == (PhBattleCommand)i ? '>' : ' ', + commands[i]); + SDL_RenderDebugText(renderer, 18.0f, + panel.y + 8.0f + (float)i * 14.0f, text); + } + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + SDL_RenderDebugText(renderer, 104.0f, panel.y + 8.0f, battle->message); + snprintf(text, sizeof(text), "HP %d/%d", player->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->mp, player_stats.max_mp); + SDL_RenderDebugText(renderer, ph_ui_right(80.0f), ph_ui_bottom(24.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, spell_panel.x + 8.0f, + spell_panel.y + 6.0f, "MAGICK"); + for (i = 0; i < battle->spells.learned_count; ++i) { + const PhSpellDef *choice = ph_spell_def(battle, + battle->spells.learned[i]); + + if (!choice) continue; + snprintf(text, sizeof(text), "%c %s %dMP%s", + battle->spell == i ? '>' : ' ', choice->name, choice->mp_cost, + choice->delay_percent < 100 ? " QUICK" : ""); + SDL_RenderDebugText(renderer, spell_panel.x + 8.0f, + spell_panel.y + 20.0f + (float)i * 12.0f, text); + } + } +} + +static void +ph_draw_battle_end(SDL_Renderer *renderer, const PhWorld *world, + const PhBattle *battle, PhGameMode game) +{ + const PhItemDef *loot = ph_world_item_def(world, battle->loot_item_id); + char text[96]; + + if (game == PH_GAME_VICTORY) + SDL_SetRenderDrawColor(renderer, PH_BATTLE_BG_COLOR); + else + SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR); + SDL_RenderClear(renderer); + SDL_SetRenderDrawColor(renderer, PH_MENU_TEXT_COLOR); + if (game == PH_GAME_OVER) { + SDL_RenderDebugText(renderer, ph_ui_center_text("GAME OVER"), + PH_VIEW_H * 0.5f - 12.0f, "GAME OVER"); + return; + } + SDL_RenderDebugText(renderer, ph_ui_center_text("VICTORY!"), + PH_VIEW_H * 0.5f - 44.0f, "VICTORY!"); + snprintf(text, sizeof(text), "XP GAINED %d", battle->xp_reward); + SDL_RenderDebugText(renderer, ph_ui_center_text(text), + PH_VIEW_H * 0.5f - 14.0f, text); + if (loot && battle->loot_amount > 0) + snprintf(text, sizeof(text), "LOOT %s x%d", loot->name, battle->loot_amount); + else + snprintf(text, sizeof(text), "LOOT NONE"); + SDL_RenderDebugText(renderer, ph_ui_center_text(text), + PH_VIEW_H * 0.5f + 4.0f, text); + SDL_RenderDebugText(renderer, ph_ui_center_text("ENTER TO CONTINUE"), + PH_VIEW_H * 0.5f + 44.0f, "ENTER TO CONTINUE"); +} + +void +ph_present_frame(SDL_Renderer *renderer, const PhAssets *assets, + const PhGameContext *context) +{ + const PhWorld *world = context->world; + const PhBattle *battle = context->battle; + PhGameMode game = *context->mode; + + if (game == PH_GAME_BATTLE) { + ph_draw_battle(renderer, assets, world, battle); + SDL_RenderPresent(renderer); + return; + } + if (game == PH_GAME_VICTORY || game == PH_GAME_OVER) { + ph_draw_battle_end(renderer, world, battle, game); + SDL_RenderPresent(renderer); + return; + } + + SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR); + SDL_RenderClear(renderer); + ph_draw_area(renderer, assets, world); + ph_draw_items(renderer, assets, world); + ph_draw_entities(renderer, assets, world); + if (context->menu == PH_MENU_CHARACTER) { + 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_SHOP) { + ph_draw_shop(renderer, world, context->shop); + } else if (world->notice_seconds > 0.0f && world->notice[0] != '\0') { + SDL_FRect notice_bg = { + .x = 8.0f, + .y = (float)(PH_VIEW_H - 28), + .w = (float)(PH_VIEW_W - 16), + .h = 20.0f, + }; + + SDL_SetRenderDrawColor(renderer, PH_NOTICE_COLOR); + SDL_RenderFillRect(renderer, &notice_bg); + SDL_SetRenderDrawColor(renderer, PH_NOTICE_TEXT_COLOR); + SDL_RenderDebugText(renderer, 14.0f, (float)(PH_VIEW_H - 22), world->notice); + } + if (game == PH_GAME_TRANSITION) { + float progress = 1.0f - battle->timer / PH_BATTLE_TRANSITION_SECONDS; + float height = progress * PH_VIEW_H * 0.5f; + SDL_FRect top = { 0.0f, 0.0f, PH_VIEW_W, height }; + SDL_FRect bottom = { 0.0f, PH_VIEW_H - height, PH_VIEW_W, height }; + + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_RenderFillRect(renderer, &top); + SDL_RenderFillRect(renderer, &bottom); + } + SDL_RenderPresent(renderer); +} + +#endif diff --git a/src/game/presentation.h b/src/game/presentation.h @@ -0,0 +1,86 @@ +#ifndef PH_GAME_PRESENTATION_H +#define PH_GAME_PRESENTATION_H + +#include "engine/battle.h" +#include "game/content.h" + +#if PH_USE_SDL +#include <SDL3/SDL.h> + +typedef struct { + SDL_Texture *textures[PH_ASSET_COUNT]; +} PhAssets; + +typedef enum { + PH_MENU_NONE, + PH_MENU_CHARACTER, + PH_MENU_INVENTORY, + PH_MENU_SHOP, +} PhMenu; + +typedef enum { + PH_GAME_WORLD, + PH_GAME_TRANSITION, + PH_GAME_BATTLE, + PH_GAME_VICTORY, + PH_GAME_OVER, +} PhGameMode; + +typedef enum { + PH_INVENTORY_BROWSE, + PH_INVENTORY_ACTIONS, + PH_INVENTORY_EXAMINE, +} PhInventoryMode; + +typedef enum { + PH_INVENTORY_EQUIP, + PH_INVENTORY_USE, + PH_INVENTORY_DROP, + PH_INVENTORY_EXAMINE_ACTION, + PH_INVENTORY_ACTION_COUNT, +} PhInventoryAction; + +typedef struct { + int selected; + int option; + PhInventoryMode mode; + char status[64]; +} PhInventoryUi; + +typedef enum { + PH_SHOP_ROOT, + PH_SHOP_BUY, + PH_SHOP_SELL, + PH_SHOP_TALK, +} PhShopMode; + +typedef struct { + int shop_id; + int selected; + PhShopMode mode; + char status[64]; +} PhShopUi; + +typedef struct { + PhWorld *world; + PhInventoryUi *inventory; + PhShopUi *shop; + PhGameMode *mode; + PhBattle *battle; + PhMenu menu; +} PhGameContext; + +int ph_load_assets(PhAssets *assets, SDL_Renderer *renderer); +void ph_destroy_assets(PhAssets *assets); +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_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, SDL_Scancode key); +void ph_present_frame(SDL_Renderer *renderer, const PhAssets *assets, + const PhGameContext *context); + +#endif + +#endif diff --git a/src/tests/render.c b/src/tests/render.c @@ -0,0 +1,266 @@ +#include "engine/battle.h" +#include "game/area.h" +#include "game/content.h" +#include "game/presentation.h" + +#include <stdio.h> + +#include "config.h" + +#if PH_USE_SDL +int +main(void) +{ + SDL_Surface *surface; + SDL_Renderer *renderer; + PhAssets assets; + PhWorld world; + PhInventoryUi inventory_ui; + PhShopUi shop_ui = { 0 }; + PhGameMode game = PH_GAME_WORLD; + PhBattle battle = { 0 }; + PhGameContext context = { &world, &inventory_ui, &shop_ui, &game, &battle, + PH_MENU_NONE }; + int i; + int enemy_index = -1; + int result = 0; + int order[PH_BATTLE_ORDER_COUNT]; + PhBattleResult battle_result; + int first_item; + int second_item; + + if (!SDL_Init(SDL_INIT_VIDEO)) { + fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); + return 1; + } + + surface = SDL_CreateSurface(PH_VIEW_W, PH_VIEW_H, SDL_PIXELFORMAT_ARGB8888); + if (!surface) { + fprintf(stderr, "SDL_CreateSurface failed: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + + renderer = SDL_CreateSoftwareRenderer(surface); + if (!renderer) { + fprintf(stderr, "SDL_CreateSoftwareRenderer failed: %s\n", SDL_GetError()); + SDL_DestroySurface(surface); + SDL_Quit(); + return 1; + } + + if (ph_game_world_init(&world, (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) { + SDL_DestroyRenderer(renderer); + SDL_DestroySurface(surface); + SDL_Quit(); + return 1; + } + if (world.ground_item_count < 2) { + fprintf(stderr, "render smoke test failed: item setup\n"); + ph_area_free(&world.area); + SDL_DestroyRenderer(renderer); + SDL_DestroySurface(surface); + SDL_Quit(); + return 1; + } + first_item = world.ground_items[0].item_id; + second_item = world.ground_items[1].item_id; + + if (ph_load_assets(&assets, renderer) < 0) { + ph_area_free(&world.area); + SDL_DestroyRenderer(renderer); + SDL_DestroySurface(surface); + SDL_Quit(); + return 1; + } + + ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); + if (ph_world_item_amount(&world, first_item) != 0) { + fprintf(stderr, "render smoke test failed: adjacent item pickup\n"); + result = 1; + } + ph_world_tick(&world, (PhInput){ .move_x = 1 }, + (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f); + ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); + ph_world_tick(&world, (PhInput){ .move_y = 1 }, + (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f); + ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); + if (ph_world_item_amount(&world, first_item) != 1 || + ph_world_item_amount(&world, second_item) != 1) { + fprintf(stderr, "render smoke test failed: item pickup\n"); + result = 1; + } + ph_inventory_open(&inventory_ui, &world); + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT); + if (inventory_ui.option != PH_INVENTORY_EQUIP) result = 1; + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); + if (inventory_ui.option != PH_INVENTORY_DROP) result = 1; + inventory_ui.mode = PH_INVENTORY_BROWSE; + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT); + if (inventory_ui.option != PH_INVENTORY_USE) result = 1; + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1); + if (inventory_ui.option != PH_INVENTORY_DROP) { + fprintf(stderr, "render smoke test failed: inventory actions\n"); + result = 1; + } + inventory_ui.mode = PH_INVENTORY_EXAMINE; + if (ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL) || + inventory_ui.mode != PH_INVENTORY_ACTIONS || + ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL) || + inventory_ui.mode != PH_INVENTORY_BROWSE || + !ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_CANCEL)) { + fprintf(stderr, "render smoke test failed: inventory cancel\n"); + result = 1; + } + ph_inventory_open(&inventory_ui, &world); + for (i = 0; i < 10; ++i) { + inventory_ui.mode = i == 8 ? PH_INVENTORY_EXAMINE : + i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE; + context.menu = i == 7 ? PH_MENU_CHARACTER : + i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE; + 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; + shop_ui.selected = i == PH_SHOP_SELL ? + ph_inventory_step(&world, -1, 1) : 0; + context.menu = PH_MENU_SHOP; + ph_present_frame(renderer, &assets, &context); + } + for (i = 0; i < world.entity_count; ++i) { + const PhEntityDef *def = ph_world_entity_def(&world, world.entities[i].type_id); + if (def && def->kind == PH_ENTITY_MONSTER) enemy_index = i; + } + if (enemy_index < 0) { + fprintf(stderr, "render smoke test failed: enemy setup\n"); + result = 1; + } else { + world.encounter_index = enemy_index; + game = PH_GAME_WORLD; + context.menu = PH_MENU_NONE; + ph_battle_begin(&battle, &world, enemy_index, ph_player_spellbook); + game = PH_GAME_TRANSITION; + ph_present_frame(renderer, &assets, &context); + if (game != PH_GAME_TRANSITION) result = 1; + context.menu = PH_MENU_NONE; + ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS * 0.5f, 1); + ph_present_frame(renderer, &assets, &context); + game = PH_GAME_BATTLE; + battle.phase = PH_BATTLE_ENEMY_ACTION; + battle.timer = 0.0f; + battle.resolved = 1; + world.entities[world.player_index].hp = 0; + context.menu = PH_MENU_NONE; + battle_result = ph_battle_update(&battle, &world, 0.0f, 0); + if (battle_result == PH_BATTLE_DEFEAT) game = PH_GAME_OVER; + ph_present_frame(renderer, &assets, &context); + 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, ph_player_spellbook); + game = PH_GAME_TRANSITION; + context.menu = PH_MENU_NONE; + if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) == + PH_BATTLE_READY) game = PH_GAME_BATTLE; + ph_present_frame(renderer, &assets, &context); + world.entities[enemy_index].hp = + ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp; + battle.command = PH_BATTLE_FIGHT; + 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 = PH_BATTLE_MAGICK; + ph_battle_order(&battle, &world, order); + if (order[0] != world.player_index || order[1] != enemy_index) { + fprintf(stderr, "render smoke test failed: command CTB order\n"); + 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_battle_order(&battle, &world, order); + if (order[0] != world.player_index || order[1] != world.player_index) { + fprintf(stderr, "render smoke test failed: spell CTB order\n"); + result = 1; + } + context.menu = PH_MENU_NONE; + ph_present_frame(renderer, &assets, &context); + ph_battle_cast(&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); + context.menu = PH_MENU_NONE; + ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); + ph_present_frame(renderer, &assets, &context); + 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 = PH_BATTLE_FIGHT; + ph_battle_select(&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); + context.menu = PH_MENU_NONE; + battle_result = ph_battle_update(&battle, &world, + PH_BATTLE_ACTION_SECONDS * 0.5f, 0); + if (battle_result == PH_BATTLE_VICTORY) game = PH_GAME_VICTORY; + ph_present_frame(renderer, &assets, &context); + if (game != PH_GAME_VICTORY || ph_world_player(&world)->xp != battle.xp_reward || + ph_world_item_amount(&world, battle.loot_item_id) != 2) { + fprintf(stderr, "render smoke test failed: victory rewards\n"); + result = 1; + } + } + game = PH_GAME_WORLD; + for (i = 0; i < world.area.marker_count; ++i) { + const PhMarkerDef *def = ph_game_marker_def(world.area_id, + world.area.markers[i].symbol); + + if (!def || def->kind != PH_MARKER_PORTAL) continue; + world.entities[world.player_index].pos = (PhVec2){ + PH_TILE_CENTER(world.area.markers[i].tile_x), + PH_TILE_CENTER(world.area.markers[i].tile_y) }; + break; + } + if (i == world.area.marker_count || ph_game_update_portal(&world) != 1) { + fprintf(stderr, "render smoke test failed: town portal\n"); + result = 1; + } + ph_shop_open(&shop_ui, ph_shop_defs[0].id); + shop_ui.mode = PH_SHOP_TALK; + context.menu = PH_MENU_SHOP; + ph_present_frame(renderer, &assets, &context); + + if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) { + fprintf(stderr, "SDL_SaveBMP failed: %s\n", SDL_GetError()); + result = 1; + } + + ph_destroy_assets(&assets); + ph_area_free(&world.area); + SDL_DestroyRenderer(renderer); + SDL_DestroySurface(surface); + SDL_Quit(); + return result; +} + + +#else +int +main(void) +{ + fprintf(stderr, "SDL3 not available in this build\n"); + return 1; +} +#endif diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -1,6 +1,6 @@ #include "engine/world.h" +#include "engine/battle.h" #include "game/area.h" -#include "game/battle.h" #include "game/content.h" #include "config.h" @@ -186,7 +186,7 @@ ph_logic_test(int open, int blocked) world.entities[enemy_index].pos = (PhVec2){ player->pos.x - PH_TILE_SIZE, player->pos.y }; ph_world_tick(&world, (PhInput){ .move_x = -1 }, crossing_seconds); - ph_battle_begin(&battle, &world, enemy_index); + ph_battle_begin(&battle, &world, enemy_index, ph_player_spellbook); if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) != PH_BATTLE_READY) return 1; battle.command = PH_BATTLE_FIGHT; @@ -233,8 +233,11 @@ ph_enter_portal(PhWorld *world) const PhAreaMarker *marker = &world->area.markers[i]; const PhMarkerDef *def = ph_game_marker_def(world->area_id, marker->symbol); + const PhTileDef *tile; if (!def || def->kind != PH_MARKER_PORTAL) continue; + tile = ph_area_tile_def(&world->area, marker->tile_x, marker->tile_y); + if (!tile || tile->symbol != 'T') return -1; player->pos = (PhVec2){ PH_TILE_CENTER(marker->tile_x), PH_TILE_CENTER(marker->tile_y) }; return ph_game_update_portal(world);