commit 0a1b42c8e5d5306a911ca1c2a6696659550341e3
parent 60ea9097738a1caea17a654d1ab1c720d9c3b954
Author: beep <beep@wimdupont.com>
Date: Thu, 30 Jul 2026 07:49:18 +0000
Separate presentation from game orchestration
Diffstat:
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, ¬ice_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, ¬ice_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);