commit 9806463a906b8aea502c4c79932fbedc82cc5f4d
parent 51b7345bc3712bb83e2a2f9e2e2b0efadee20a2d
Author: beep <beep@wimdupont.com>
Date: Wed, 29 Jul 2026 14:19:45 +0000
Add Stonehollow town and shop interactions
Diffstat:
15 files changed, 774 insertions(+), 139 deletions(-)
diff --git a/Makefile b/Makefile
@@ -3,7 +3,7 @@ SMOKE = bin/ph-smoke
MAPC = bin/ph-mapc
OBJDIR = obj
MAPDIR = $(OBJDIR)/maps
-MAPSRC = data/maps/ashen-meadow.txt
+MAPSRC = data/maps/ashen-meadow.txt data/maps/stonehollow.txt
MAPBIN = $(MAPSRC:data/maps/%.txt=$(MAPDIR)/%.phmap)
TILESET_SRC = assets/vendor/overworld-grass-biome/TilesetGrass/overworld_tileset_grass.png
TILESET_BMP = $(OBJDIR)/assets/overworld_tileset_grass.bmp
@@ -16,6 +16,7 @@ CRITTERS_BMP = $(OBJDIR)/assets/critters.bmp
SRC = \
src/engine/world.c \
+ src/game/area.c \
src/game/battle.c \
src/game/content.c \
src/game/main.c
@@ -88,7 +89,7 @@ $(CRITTERS_BMP): $(CRITTERS_SRC)
@mkdir -p $(dir $@)
magick $< -alpha off BMP3:$@
-smoke: $(SMOKE)
+smoke: $(MAPBIN) $(SMOKE)
$(SMOKE)
render-smoke: $(BIN)
diff --git a/README.adoc b/README.adoc
@@ -15,6 +15,8 @@ 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.
+`src/game/area.c` loads content-defined maps and portals while preserving the
+state of entities and ground items belonging to inactive areas.
World actors update even while the player stands still. Entity content defines
relative movement speed, directional animation frames, vision, and aggression;
@@ -70,6 +72,10 @@ Entity spawns also own instance-specific territory radii; global world movement
pace, actor decision timing, wandering chance, and pursuit memory remain settings
in `config.h`.
+Items define distinct buy and sell prices. The player carries gold, and shop
+content defines stock and dialogue without embedding merchant-specific rules in
+the renderer. Potions restore HP; Ether restores MP.
+
The SDL window is runtime-resizable. Rendering stays on the configured logical
canvas and uses integer presentation scaling; right-, bottom-, and center-aligned
interface elements are derived from that canvas rather than a fixed 320x240
@@ -117,7 +123,11 @@ Map source files live in `data/maps/*.txt`.
`make` compiles them into binary `obj/maps/*.phmap` files with `bin/ph-mapc`,
and the game loads those binary map files at runtime.
-Current tile legend:
+Current tile legend across the meadow and Stonehollow town maps:
* `.` = walkable ground
* `#` = blocked wall
+* `,` = walkable town path
+* `B` = blocked building wall
+* `=` = walkable building floor or doorway
+* `T` = walkable area portal
diff --git a/config.def.h b/config.def.h
@@ -1,7 +1,7 @@
#ifndef CONFIG_H
#define CONFIG_H
-#define PH_CONFIG_VERSION 6
+#define PH_CONFIG_VERSION 7
#define PH_VIEW_W 320
#define PH_VIEW_H 240
@@ -12,6 +12,7 @@
#define PH_ENTITY_BLOCK_RADIUS 10
#define PH_PICKUP_RADIUS 18.0f
+#define PH_INTERACT_RADIUS 20.0f
#define PH_NOTICE_SECONDS 1.25f
#define PH_WORLD_MOVE_PIXELS_PER_SECOND 45
#define PH_WORLD_ACTION_MILLISECONDS 500
@@ -30,7 +31,6 @@
#define PH_PHYSICAL_DEFENSE_DIVISOR 2
#define PH_MAGIC_DEFENSE_DIVISOR 2
-#define PH_START_MAP_PATH "obj/maps/ashen-meadow.phmap"
#define PH_RENDER_SMOKE_PATH "obj/render-smoke.bmp"
#define PH_CLEAR_COLOR 16, 18, 24, 255
diff --git a/data/maps/ashen-meadow.txt b/data/maps/ashen-meadow.txt
@@ -2,7 +2,7 @@ name: Ashen Meadow
################################
#..............................#
-#..............................#
+#............................T.#
#......####....................#
#......#..#....................#
#......####............###.....#
diff --git a/data/maps/stonehollow.txt b/data/maps/stonehollow.txt
@@ -0,0 +1,26 @@
+name: Stonehollow
+
+################################
+#..............................#
+#..BBBBBBBB....BBBBBBBB........#
+#..B======B....B======B........#
+#..B======B....B======B........#
+#..B======B....B======B........#
+#..BBB=BBBB....BBBB=BBB........#
+#.....,............,...........#
+#.....,,,,,,,,,,,,,,...........#
+#...........,..................#
+#..BBBBBBBB.,..BBBBBBBB........#
+#..B======B.,..B======B........#
+#..B======B.,..B======B........#
+#..B======B.,..B======B........#
+#..BBB=BBBB.,..BBB=BBBB........#
+#.....,.....,.....,............#
+#.....,,,,,,,,,,,,,............#
+#...........,..................#
+#...........,,,,,..............#
+#..............,...............#
+#..............,...............#
+#..............,...............#
+#..............T...............#
+################################
diff --git a/phantasia.6 b/phantasia.6
@@ -9,15 +9,15 @@
.Op Fl -render-smoke-test
.Sh DESCRIPTION
.Nm
-is a small top-down RPG prototype with map movement, item pickup, visible
-in-world encounters, independently roaming and pursuing monsters, and turn-based
-battles.
+is a small top-down RPG prototype with connected map areas, item pickup, shops,
+visible in-world encounters, independently roaming and pursuing monsters, and
+turn-based battles.
.Sh KEYBINDINGS
.Bl -tag -width "Arrow keys / WASD"
.It Arrow keys / WASD
Move the player.
.It Space / E
-Interact and pick up nearby loot.
+Interact, pick up nearby loot, or speak to a facing shopkeeper.
.It C
Open or close the character sheet.
.It I
@@ -25,7 +25,8 @@ Open or close the inventory.
.It Arrow keys / HJKL
Move through inventory items, item actions, battle commands, and learned spells.
.It Enter
-Open or confirm the selected inventory action, battle command, or spell.
+Open or confirm the selected inventory action, shop choice, battle command, or
+spell.
.It Escape
Return one level from an inventory or battle submenu, or close an open character
or top-level inventory window.
diff --git a/src/engine/world.c b/src/engine/world.c
@@ -137,7 +137,7 @@ ph_world_blocking_entity(const PhWorld *world, int self_index, PhVec2 pos)
const PhEntity *entity = &world->entities[i];
const PhEntityDef *def;
- if (i == self_index || !entity->active) {
+ if (i == self_index || !entity->active || entity->area_id != world->area_id) {
continue;
}
@@ -209,7 +209,7 @@ ph_try_pickup(PhWorld *world, PhEntity *entity)
PhGroundItem *drop = &world->ground_items[i];
int item_index;
- if (!drop->active) {
+ if (!drop->active || drop->area_id != world->area_id) {
continue;
}
if (ph_dist2(entity->pos, drop->pos) > PH_PICKUP_RADIUS * PH_PICKUP_RADIUS) {
@@ -222,6 +222,32 @@ ph_try_pickup(PhWorld *world, PhEntity *entity)
}
}
+static void
+ph_try_interact(PhWorld *world, const PhEntity *player)
+{
+ PhVec2 target = {
+ player->pos.x + (float)(player->facing_x * PH_TILE_SIZE),
+ player->pos.y + (float)(player->facing_y * PH_TILE_SIZE),
+ };
+ float closest = PH_INTERACT_RADIUS * PH_INTERACT_RADIUS;
+ int i;
+
+ for (i = 0; i < world->entity_count; ++i) {
+ const PhEntity *entity = &world->entities[i];
+ const PhEntityDef *def;
+ float distance;
+
+ if (i == world->player_index || !entity->active ||
+ entity->area_id != world->area_id) continue;
+ def = ph_world_entity_def(world, entity->type_id);
+ if (!def || def->interaction_id <= 0) continue;
+ distance = ph_dist2(entity->pos, target);
+ if (distance > closest) continue;
+ closest = distance;
+ world->interaction_id = def->interaction_id;
+ }
+}
+
static PhInput
ph_orthogonal_input(PhInput input)
{
@@ -357,7 +383,8 @@ ph_world_advance_movers(PhWorld *world, float dt)
float distance;
float travel;
- if (i == world->player_index || !entity->active || !entity->moving) continue;
+ if (i == world->player_index || !entity->active || !entity->moving ||
+ entity->area_id != world->area_id) continue;
def = ph_world_entity_def(world, entity->type_id);
if (!def || def->move_speed <= 0) continue;
dx = entity->move_target.x - entity->pos.x;
@@ -387,7 +414,7 @@ ph_world_update_actors(PhWorld *world, float dt)
const PhEntityDef *def = ph_world_entity_def(world, entity->type_id);
if (!def || def->kind == PH_ENTITY_PLAYER || def->move_speed <= 0 ||
- !entity->active) continue;
+ !entity->active || entity->area_id != world->area_id) continue;
entity->move_credit += (float)def->move_speed * dt;
if (entity->move_credit > action_seconds) entity->move_credit = action_seconds;
if (entity->moving || entity->move_credit < action_seconds) continue;
@@ -397,10 +424,12 @@ ph_world_update_actors(PhWorld *world, float dt)
}
void
-ph_world_init(PhWorld *world, PhArea area, float viewport_w, float viewport_h)
+ph_world_init(PhWorld *world, PhArea area, int area_id,
+ float viewport_w, float viewport_h)
{
memset(world, 0, sizeof(*world));
world->area = area;
+ world->area_id = area_id;
world->camera.viewport_w = viewport_w;
world->camera.viewport_h = viewport_h;
world->player_index = -1;
@@ -408,6 +437,26 @@ ph_world_init(PhWorld *world, PhArea area, float viewport_w, float viewport_h)
world->rng_state = 0x5048414eu;
}
+void
+ph_world_enter_area(PhWorld *world, PhArea area, int area_id, PhVec2 player_pos)
+{
+ PhEntity *player;
+
+ ph_area_free(&world->area);
+ world->area = area;
+ world->area_id = area_id;
+ world->encounter_index = -1;
+ world->interaction_id = 0;
+ player = world->player_index >= 0 && world->player_index < world->entity_count ?
+ &world->entities[world->player_index] : NULL;
+ if (!player) return;
+ player->area_id = area_id;
+ player->pos = player->move_target = player->home = player_pos;
+ player->moving = 0;
+ player->animation_time = 0.0f;
+ ph_camera_follow_player(world);
+}
+
int
ph_area_load(PhArea *area, const char *path)
{
@@ -497,7 +546,7 @@ ph_world_add_item_def(PhWorld *world, PhItemDef def)
int
ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos,
- int territory_radius)
+ int territory_radius, int area_id)
{
PhEntity *entity;
const PhEntityDef *def;
@@ -514,12 +563,14 @@ ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos,
entity = &world->entities[world->entity_count];
memset(entity, 0, sizeof(*entity));
entity->type_id = type_id;
+ entity->area_id = area_id;
entity->pos = pos;
entity->move_target = pos;
entity->home = pos;
entity->last_seen = pos;
entity->hp = def->stats.max_hp;
entity->mp = def->stats.max_mp;
+ entity->gold = def->starting_gold;
entity->facing_x = 0;
entity->facing_y = 1;
entity->territory_radius = territory_radius > 0 ? territory_radius : 0;
@@ -529,7 +580,8 @@ ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos,
}
int
-ph_world_drop_item(PhWorld *world, int item_id, PhVec2 pos, int amount)
+ph_world_drop_item(PhWorld *world, int item_id, PhVec2 pos, int amount,
+ int area_id)
{
PhGroundItem *drop = NULL;
int i;
@@ -548,6 +600,7 @@ ph_world_drop_item(PhWorld *world, int item_id, PhVec2 pos, int amount)
}
drop->item_id = item_id;
+ drop->area_id = area_id;
drop->pos = pos;
drop->amount = amount;
drop->active = 1;
@@ -626,10 +679,16 @@ ph_world_use_inventory_item(PhWorld *world, int entity_index, int item_id)
if (world->inventory[item_index] <= 0) return -1;
entity = &world->entities[entity_index];
stats = ph_world_entity_stats(world, entity);
- if (item->use != PH_ITEM_USE_HEAL || item->use_power <= 0 ||
- entity->hp >= stats.max_hp) return -1;
- entity->hp += item->use_power;
- if (entity->hp > stats.max_hp) entity->hp = stats.max_hp;
+ if (item->use_power <= 0) return -1;
+ if (item->use == PH_ITEM_USE_HEAL && entity->hp < stats.max_hp) {
+ entity->hp += item->use_power;
+ if (entity->hp > stats.max_hp) entity->hp = stats.max_hp;
+ } else if (item->use == PH_ITEM_USE_RESTORE_MP && entity->mp < stats.max_mp) {
+ entity->mp += item->use_power;
+ if (entity->mp > stats.max_mp) entity->mp = stats.max_mp;
+ } else {
+ return -1;
+ }
--world->inventory[item_index];
return 0;
}
@@ -642,13 +701,50 @@ ph_world_drop_inventory_item(PhWorld *world, int entity_index, int item_id)
if (entity_index < 0 || entity_index >= world->entity_count) return -1;
item_index = ph_item_def_index(world, item_id);
if (item_index < 0 || world->inventory[item_index] <= 0) return -1;
- if (ph_world_drop_item(world, item_id, world->entities[entity_index].pos, 1) < 0)
+ if (ph_world_drop_item(world, item_id, world->entities[entity_index].pos, 1,
+ world->area_id) < 0)
return -1;
--world->inventory[item_index];
return 0;
}
int
+ph_world_buy_item(PhWorld *world, int entity_index, int item_id)
+{
+ PhEntity *entity;
+ const PhItemDef *item;
+ int item_index;
+
+ if (entity_index < 0 || entity_index >= world->entity_count) return -1;
+ item = ph_world_item_def(world, item_id);
+ item_index = ph_item_def_index(world, item_id);
+ if (!item || item_index < 0 || item->buy_value <= 0) return -1;
+ entity = &world->entities[entity_index];
+ if (entity->gold < item->buy_value) return -1;
+ entity->gold -= item->buy_value;
+ ++world->inventory[item_index];
+ return 0;
+}
+
+int
+ph_world_sell_item(PhWorld *world, int entity_index, int item_id)
+{
+ PhEntity *entity;
+ const PhItemDef *item;
+ int item_index;
+
+ if (entity_index < 0 || entity_index >= world->entity_count) return -1;
+ item = ph_world_item_def(world, item_id);
+ item_index = ph_item_def_index(world, item_id);
+ if (!item || item_index < 0 || item->sell_value <= 0 ||
+ world->inventory[item_index] <= 0) return -1;
+ entity = &world->entities[entity_index];
+ --world->inventory[item_index];
+ entity->gold += item->sell_value;
+ return 0;
+}
+
+int
ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index)
{
PhEntity *attacker;
@@ -802,6 +898,7 @@ ph_world_tick(PhWorld *world, PhInput input, float dt)
}
input = ph_orthogonal_input(input);
+ world->interaction_id = 0;
before = player->pos;
next = player->pos;
if ((input.move_x != 0 || input.move_y != 0) && def->move_speed > 0) {
@@ -821,6 +918,7 @@ ph_world_tick(PhWorld *world, PhInput input, float dt)
if (input.interact) {
ph_try_pickup(world, player);
+ ph_try_interact(world, player);
}
ph_world_update_actors(world, dt);
diff --git a/src/engine/world.h b/src/engine/world.h
@@ -3,7 +3,7 @@
#include <stddef.h>
-#define PH_CONFIG_API_VERSION 6
+#define PH_CONFIG_API_VERSION 7
enum {
PH_MAP_MAGIC = 0x50484d50,
@@ -31,6 +31,7 @@ typedef enum {
typedef enum {
PH_ITEM_USE_NONE,
PH_ITEM_USE_HEAL,
+ PH_ITEM_USE_RESTORE_MP,
} PhItemUse;
typedef enum {
@@ -79,6 +80,8 @@ typedef struct {
int move_speed;
int vision;
int aggressive;
+ int starting_gold;
+ int interaction_id;
int xp_reward;
int loot_item_id;
int loot_amount;
@@ -94,7 +97,8 @@ typedef struct {
int id;
const char *name;
const char *description;
- int value;
+ int buy_value;
+ int sell_value;
PhStats bonuses;
PhEquipSlot equip_slot;
PhItemUse use;
@@ -106,6 +110,7 @@ typedef struct {
typedef struct {
int type_id;
+ int area_id;
PhVec2 pos;
PhVec2 move_target;
PhVec2 home;
@@ -113,6 +118,7 @@ typedef struct {
int hp;
int mp;
int xp;
+ int gold;
int accuracy_penalty;
int accuracy_turns;
int facing_x;
@@ -128,6 +134,7 @@ typedef struct {
typedef struct {
int item_id;
+ int area_id;
PhVec2 pos;
int amount;
int active;
@@ -157,12 +164,14 @@ typedef struct {
PhEntity entities[PH_MAX_ENTITIES];
PhGroundItem ground_items[PH_MAX_GROUND_ITEMS];
int player_index;
+ int area_id;
int entity_def_count;
int item_def_count;
int entity_count;
int ground_item_count;
int inventory[PH_MAX_ITEM_TYPES];
int encounter_index;
+ int interaction_id;
unsigned int rng_state;
char notice[PH_NOTICE_SIZE];
float notice_seconds;
@@ -177,17 +186,23 @@ typedef struct {
int ph_area_load(PhArea *area, const char *path);
void ph_area_free(PhArea *area);
-void ph_world_init(PhWorld *world, PhArea area, float viewport_w, float viewport_h);
+void ph_world_init(PhWorld *world, PhArea area, int area_id,
+ float viewport_w, float viewport_h);
+void ph_world_enter_area(PhWorld *world, PhArea area, int area_id,
+ PhVec2 player_pos);
int ph_world_add_entity_def(PhWorld *world, PhEntityDef def);
int ph_world_add_item_def(PhWorld *world, PhItemDef def);
int ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos,
- int territory_radius);
-int ph_world_drop_item(PhWorld *world, int item_id, PhVec2 pos, int amount);
+ int territory_radius, int area_id);
+int ph_world_drop_item(PhWorld *world, int item_id, PhVec2 pos, int amount,
+ int area_id);
void ph_world_set_player(PhWorld *world, int entity_index);
int ph_world_equip_item(PhWorld *world, int entity_index, int item_id);
int ph_world_equip_inventory_item(PhWorld *world, int entity_index, int item_id);
int ph_world_use_inventory_item(PhWorld *world, int entity_index, int item_id);
int ph_world_drop_inventory_item(PhWorld *world, int entity_index, int item_id);
+int ph_world_buy_item(PhWorld *world, int entity_index, int item_id);
+int ph_world_sell_item(PhWorld *world, int entity_index, int item_id);
int ph_world_physical_attack(PhWorld *world, int attacker_index, int target_index);
int ph_world_magic_attack(PhWorld *world, int attacker_index, int target_index,
int mp_cost, int power);
diff --git a/src/game/area.c b/src/game/area.c
@@ -0,0 +1,110 @@
+#include "game/area.h"
+
+#include <math.h>
+
+#define LEN(a) (sizeof(a) / sizeof(0[a]))
+
+const PhAreaDef *
+ph_game_area_def(int area_id)
+{
+ size_t i;
+
+ for (i = 0; i < LEN(ph_area_defs); ++i)
+ if (ph_area_defs[i].id == area_id) return &ph_area_defs[i];
+ return NULL;
+}
+
+const PhShopDef *
+ph_game_shop_def(int shop_id)
+{
+ size_t i;
+
+ for (i = 0; i < LEN(ph_shop_defs); ++i)
+ if (ph_shop_defs[i].id == shop_id) return &ph_shop_defs[i];
+ return NULL;
+}
+
+const PhInteractionDef *
+ph_game_interaction_def(int interaction_id)
+{
+ size_t i;
+
+ for (i = 0; i < LEN(ph_interaction_defs); ++i)
+ if (ph_interaction_defs[i].id == interaction_id)
+ return &ph_interaction_defs[i];
+ return NULL;
+}
+
+static int
+ph_game_load_area(PhArea *area, int area_id)
+{
+ const PhAreaDef *def = ph_game_area_def(area_id);
+
+ if (!def || ph_area_load(area, def->path) < 0) return -1;
+ area->tile_defs = ph_tile_defs;
+ area->tile_def_count = (int)LEN(ph_tile_defs);
+ return 0;
+}
+
+int
+ph_game_world_init(PhWorld *world, float viewport_w, float viewport_h)
+{
+ PhArea area;
+ int player = -1;
+ size_t i;
+
+ if (ph_game_load_area(&area, PH_START_AREA) < 0) return -1;
+ ph_world_init(world, area, PH_START_AREA, viewport_w, viewport_h);
+ for (i = 0; i < LEN(ph_entity_defs); ++i)
+ if (ph_world_add_entity_def(world, ph_entity_defs[i]) < 0) goto fail;
+ for (i = 0; i < LEN(ph_item_defs); ++i)
+ if (ph_world_add_item_def(world, ph_item_defs[i]) < 0) goto fail;
+ for (i = 0; i < LEN(ph_entity_spawns); ++i) {
+ int entity = ph_world_spawn_entity(world, ph_entity_spawns[i].type_id,
+ ph_entity_spawns[i].pos, ph_entity_spawns[i].territory_radius,
+ ph_entity_spawns[i].area_id);
+
+ if (entity < 0) goto fail;
+ if (i == PH_PLAYER_SPAWN) player = entity;
+ }
+ if (player < 0) goto fail;
+ for (i = 0; i < LEN(ph_item_drops); ++i)
+ if (ph_world_drop_item(world, ph_item_drops[i].item_id,
+ ph_item_drops[i].pos, ph_item_drops[i].amount,
+ ph_item_drops[i].area_id) < 0) goto fail;
+ for (i = 0; i < LEN(ph_player_equipment); ++i)
+ if (ph_player_equipment[i] &&
+ ph_world_equip_item(world, player, ph_player_equipment[i]) < 0)
+ goto fail;
+ ph_world_set_player(world, player);
+ return 0;
+
+fail:
+ ph_area_free(&world->area);
+ return -1;
+}
+
+int
+ph_game_update_portal(PhWorld *world)
+{
+ const PhEntity *player = ph_world_player(world);
+ int tile_x;
+ int tile_y;
+ size_t i;
+
+ if (!player) return -1;
+ tile_x = (int)floorf(player->pos.x / PH_TILE_SIZE);
+ tile_y = (int)floorf(player->pos.y / PH_TILE_SIZE);
+ for (i = 0; i < LEN(ph_portal_defs); ++i) {
+ const PhPortalDef *portal = &ph_portal_defs[i];
+ PhArea area;
+
+ if (portal->area_id != world->area_id || portal->tile_x != tile_x ||
+ portal->tile_y != tile_y) continue;
+ if (ph_game_load_area(&area, portal->destination_area_id) < 0) return -1;
+ ph_world_enter_area(world, area, portal->destination_area_id,
+ portal->destination);
+ return 1;
+ }
+ return 0;
+}
diff --git a/src/game/area.h b/src/game/area.h
@@ -0,0 +1,12 @@
+#ifndef PH_GAME_AREA_H
+#define PH_GAME_AREA_H
+
+#include "game/content.h"
+
+int ph_game_world_init(PhWorld *world, float viewport_w, float viewport_h);
+int ph_game_update_portal(PhWorld *world);
+const PhAreaDef *ph_game_area_def(int area_id);
+const PhShopDef *ph_game_shop_def(int shop_id);
+const PhInteractionDef *ph_game_interaction_def(int interaction_id);
+
+#endif
diff --git a/src/game/battle.c b/src/game/battle.c
@@ -58,8 +58,11 @@ ph_battle_item(const PhWorld *world)
int i;
for (i = 0; i < world->item_def_count; ++i)
- if (world->inventory[i] > 0 && world->item_defs[i].use != PH_ITEM_USE_NONE &&
- player && player->hp < stats.max_hp) return world->item_defs[i].id;
+ 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;
}
diff --git a/src/game/content.c b/src/game/content.c
@@ -7,9 +7,18 @@ const PhAssetDef ph_asset_defs[PH_ASSET_COUNT] = {
[PH_ASSET_CRITTERS] = { "obj/assets/critters.bmp", 255, 147, 153 },
};
+const PhAreaDef ph_area_defs[PH_AREA_COUNT] = {
+ { PH_AREA_MEADOW, "obj/maps/ashen-meadow.phmap" },
+ { PH_AREA_STONEHOLLOW, "obj/maps/stonehollow.phmap" },
+};
+
const PhTileDef ph_tile_defs[PH_TILE_DEF_COUNT] = {
{ '.', 0, PH_ASSET_TILESET, 1, 1 },
{ '#', 1, PH_ASSET_TILESET, 1, 5 },
+ { ',', 0, PH_ASSET_TILESET, 5, 0 },
+ { 'B', 1, PH_ASSET_TILESET, 1, 16 },
+ { '=', 0, PH_ASSET_TILESET, 3, 14 },
+ { 'T', 0, PH_ASSET_TILESET, 6, 11 },
};
const PhSpellDef ph_spell_defs[PH_SPELL_DEF_COUNT] = {
@@ -26,6 +35,7 @@ const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT] = {
.magic = 4, .magic_defense = 3, .agility = 10, .luck = 5,
.evasion = 2, .accuracy = 100 },
.move_speed = 2,
+ .starting_gold = 40,
.asset_id = PH_ASSET_HERO,
.sprite_tiles = {
[PH_SPRITE_DOWN] = { { 4, 0 }, { 6, 0 } },
@@ -60,6 +70,20 @@ const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT] = {
.blocks_movement = 1,
.kind = PH_ENTITY_MONSTER,
},
+ {
+ .id = PH_DEF_SHOPKEEPER,
+ .name = "Mira",
+ .interaction_id = PH_INTERACTION_MIRA,
+ .asset_id = PH_ASSET_HERO,
+ .sprite_tiles = {
+ [PH_SPRITE_DOWN] = { { 5, 0 } },
+ [PH_SPRITE_UP] = { { 5, 0 } },
+ [PH_SPRITE_SIDE] = { { 5, 0 } },
+ },
+ .animation_frames = 1,
+ .blocks_movement = 1,
+ .kind = PH_ENTITY_NPC,
+ },
};
const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT] = {
@@ -67,7 +91,8 @@ const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT] = {
.id = PH_ITEM_RUSTED_SPEAR,
.name = "Rusted Spear",
.description = "A worn but sturdy spear.",
- .value = 12,
+ .buy_value = 12,
+ .sell_value = 6,
.bonuses = { .strength = 2 },
.equip_slot = PH_EQUIP_WEAPON,
.asset_id = PH_ASSET_ITEMS,
@@ -78,7 +103,8 @@ const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT] = {
.id = PH_ITEM_WAYFARER_BLADE,
.name = "Wayfarer Blade",
.description = "A reliable traveler's blade.",
- .value = 20,
+ .buy_value = 20,
+ .sell_value = 10,
.bonuses = { .strength = 3 },
.equip_slot = PH_EQUIP_WEAPON,
.asset_id = PH_ASSET_ITEMS,
@@ -89,7 +115,8 @@ const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT] = {
.id = PH_ITEM_TRAVELER_COAT,
.name = "Traveler Coat",
.description = "A coat hardened by the road.",
- .value = 18,
+ .buy_value = 18,
+ .sell_value = 9,
.bonuses = { .max_hp = 5, .defense = 2 },
.equip_slot = PH_EQUIP_ARMOR,
.asset_id = PH_ASSET_ITEMS,
@@ -100,13 +127,26 @@ const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT] = {
.id = PH_ITEM_HEALING_POTION,
.name = "Healing Potion",
.description = "Restores 15 HP.",
- .value = 8,
+ .buy_value = 8,
+ .sell_value = 4,
.use = PH_ITEM_USE_HEAL,
.use_power = 15,
.asset_id = PH_ASSET_ITEMS,
.sprite_tile_x = 3,
.sprite_tile_y = 0,
},
+ {
+ .id = PH_ITEM_ETHER,
+ .name = "Ether",
+ .description = "Restores 15 MP.",
+ .buy_value = 12,
+ .sell_value = 6,
+ .use = PH_ITEM_USE_RESTORE_MP,
+ .use_power = 15,
+ .asset_id = PH_ASSET_ITEMS,
+ .sprite_tile_x = 4,
+ .sprite_tile_y = 0,
+ },
};
const int ph_player_equipment[PH_EQUIP_COUNT] = {
@@ -115,11 +155,39 @@ const int ph_player_equipment[PH_EQUIP_COUNT] = {
};
const PhEntitySpawn ph_entity_spawns[PH_ENTITY_SPAWN_COUNT] = {
- { PH_DEF_PLAYER, { PH_TILE_CENTER(5), PH_TILE_CENTER(4) }, 0 },
- { PH_DEF_SLIME, { PH_TILE_CENTER(11), PH_TILE_CENTER(7) }, 5 },
+ { PH_AREA_MEADOW, PH_DEF_PLAYER, { PH_TILE_CENTER(5), PH_TILE_CENTER(4) }, 0 },
+ { PH_AREA_MEADOW, PH_DEF_SLIME, { PH_TILE_CENTER(11), PH_TILE_CENTER(7) }, 5 },
+ { PH_AREA_STONEHOLLOW, PH_DEF_SHOPKEEPER,
+ { PH_TILE_CENTER(5), PH_TILE_CENTER(4) }, 0 },
};
const PhItemDrop ph_item_drops[PH_ITEM_DROP_COUNT] = {
- { PH_ITEM_RUSTED_SPEAR, { PH_TILE_CENTER(6), PH_TILE_CENTER(4) }, 1 },
- { PH_ITEM_HEALING_POTION, { PH_TILE_CENTER(6), PH_TILE_CENTER(5) }, 1 },
+ { PH_AREA_MEADOW, PH_ITEM_RUSTED_SPEAR,
+ { PH_TILE_CENTER(6), PH_TILE_CENTER(4) }, 1 },
+ { PH_AREA_MEADOW, PH_ITEM_HEALING_POTION,
+ { PH_TILE_CENTER(6), PH_TILE_CENTER(5) }, 1 },
+};
+
+const PhPortalDef ph_portal_defs[PH_PORTAL_COUNT] = {
+ { PH_AREA_MEADOW, 29, 2, PH_AREA_STONEHOLLOW,
+ { PH_TILE_CENTER(15), PH_TILE_CENTER(21) } },
+ { PH_AREA_STONEHOLLOW, 15, 22, PH_AREA_MEADOW,
+ { PH_TILE_CENTER(28), PH_TILE_CENTER(2) } },
+};
+
+const PhShopDef ph_shop_defs[PH_SHOP_COUNT] = {
+ {
+ .id = PH_SHOP_STONEHOLLOW,
+ .name = "Mira's Sundries",
+ .items = { PH_ITEM_HEALING_POTION, PH_ITEM_ETHER },
+ .item_count = 2,
+ .talk = {
+ "Fine weather for adventuring.",
+ "Terrible weather for staying solvent.",
+ },
+ },
+};
+
+const PhInteractionDef ph_interaction_defs[PH_INTERACTION_COUNT] = {
+ { PH_INTERACTION_MIRA, PH_INTERACTION_SHOP, PH_SHOP_STONEHOLLOW },
};
diff --git a/src/game/content.h b/src/game/content.h
@@ -11,18 +11,54 @@ typedef struct {
} PhAssetDef;
typedef struct {
+ int id;
+ const char *path;
+} PhAreaDef;
+
+typedef struct {
+ int area_id;
int type_id;
PhVec2 pos;
int territory_radius;
} PhEntitySpawn;
typedef struct {
+ int area_id;
int item_id;
PhVec2 pos;
int amount;
} PhItemDrop;
typedef struct {
+ int area_id;
+ int tile_x;
+ int tile_y;
+ int destination_area_id;
+ PhVec2 destination;
+} PhPortalDef;
+
+enum { PH_SHOP_ITEM_MAX = 8, PH_SHOP_TALK_LINES = 2 };
+
+typedef struct {
+ int id;
+ const char *name;
+ int items[PH_SHOP_ITEM_MAX];
+ int item_count;
+ const char *talk[PH_SHOP_TALK_LINES];
+} PhShopDef;
+
+typedef enum {
+ PH_INTERACTION_NONE,
+ PH_INTERACTION_SHOP,
+} PhInteractionKind;
+
+typedef struct {
+ int id;
+ PhInteractionKind kind;
+ int content_id;
+} PhInteractionDef;
+
+typedef struct {
int id;
const char *name;
int mp_cost;
@@ -44,7 +80,8 @@ enum {
enum {
PH_DEF_PLAYER = 1,
PH_DEF_SLIME,
- PH_ENTITY_DEF_COUNT = 2,
+ PH_DEF_SHOPKEEPER,
+ PH_ENTITY_DEF_COUNT = 3,
};
enum {
@@ -52,7 +89,22 @@ enum {
PH_ITEM_WAYFARER_BLADE,
PH_ITEM_TRAVELER_COAT,
PH_ITEM_HEALING_POTION,
- PH_ITEM_DEF_COUNT = 4,
+ PH_ITEM_ETHER,
+ PH_ITEM_DEF_COUNT = 5,
+};
+
+enum {
+ PH_AREA_MEADOW = 1,
+ PH_AREA_STONEHOLLOW,
+ PH_AREA_COUNT = 2,
+ PH_START_AREA = PH_AREA_MEADOW,
+};
+
+enum { PH_SHOP_STONEHOLLOW = 1, PH_SHOP_COUNT = 1 };
+
+enum {
+ PH_INTERACTION_MIRA = 1,
+ PH_INTERACTION_COUNT = 1,
};
enum {
@@ -62,13 +114,15 @@ enum {
};
enum {
- PH_TILE_DEF_COUNT = 2,
- PH_ENTITY_SPAWN_COUNT = 2,
+ PH_TILE_DEF_COUNT = 6,
+ PH_ENTITY_SPAWN_COUNT = 3,
PH_ITEM_DROP_COUNT = 2,
+ PH_PORTAL_COUNT = 2,
PH_PLAYER_SPAWN = 0,
};
extern const PhAssetDef ph_asset_defs[PH_ASSET_COUNT];
+extern const PhAreaDef ph_area_defs[PH_AREA_COUNT];
extern const PhTileDef ph_tile_defs[PH_TILE_DEF_COUNT];
extern const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT];
extern const PhItemDef ph_item_defs[PH_ITEM_DEF_COUNT];
@@ -77,5 +131,8 @@ extern const int ph_player_spells[PH_PLAYER_SPELL_COUNT];
extern const int ph_player_equipment[PH_EQUIP_COUNT];
extern const PhEntitySpawn ph_entity_spawns[PH_ENTITY_SPAWN_COUNT];
extern const PhItemDrop ph_item_drops[PH_ITEM_DROP_COUNT];
+extern const PhPortalDef ph_portal_defs[PH_PORTAL_COUNT];
+extern const PhShopDef ph_shop_defs[PH_SHOP_COUNT];
+extern const PhInteractionDef ph_interaction_defs[PH_INTERACTION_COUNT];
#endif
diff --git a/src/game/main.c b/src/game/main.c
@@ -1,4 +1,5 @@
#include "engine/world.h"
+#include "game/area.h"
#include "game/battle.h"
#include "game/content.h"
@@ -29,6 +30,7 @@ typedef enum {
PH_MENU_NONE,
PH_MENU_CHARACTER,
PH_MENU_INVENTORY,
+ PH_MENU_SHOP,
} PhMenu;
typedef enum {
@@ -60,6 +62,29 @@ typedef struct {
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
@@ -82,55 +107,9 @@ ph_ui_center_text(const char *text)
#endif
#if PH_USE_SDL
-static int
-ph_make_world(PhWorld *world)
-{
- PhArea area;
- size_t i;
- int entity;
- int player;
-
- if (ph_area_load(&area, PH_START_MAP_PATH) < 0) {
- fprintf(stderr, "failed to load %s\n", PH_START_MAP_PATH);
- return -1;
- }
- area.tile_defs = ph_tile_defs;
- area.tile_def_count = (int)LEN(ph_tile_defs);
-
- ph_world_init(world, area, (float)PH_VIEW_W, (float)PH_VIEW_H);
- for (i = 0; i < LEN(ph_entity_defs); ++i)
- if (ph_world_add_entity_def(world, ph_entity_defs[i]) < 0) goto fail;
- for (i = 0; i < LEN(ph_item_defs); ++i)
- if (ph_world_add_item_def(world, ph_item_defs[i]) < 0) goto fail;
-
- player = -1;
- for (i = 0; i < LEN(ph_entity_spawns); ++i) {
- entity = ph_world_spawn_entity(world, ph_entity_spawns[i].type_id,
- ph_entity_spawns[i].pos, ph_entity_spawns[i].territory_radius);
- if (entity < 0) goto fail;
- if (i == PH_PLAYER_SPAWN) player = entity;
- }
- if (player < 0) goto fail;
- for (i = 0; i < LEN(ph_item_drops); ++i)
- if (ph_world_drop_item(world, ph_item_drops[i].item_id,
- ph_item_drops[i].pos, ph_item_drops[i].amount) < 0) goto fail;
- for (i = 0; i < LEN(ph_player_equipment); ++i)
- if (ph_player_equipment[i] &&
- ph_world_equip_item(world, player, ph_player_equipment[i]) < 0) goto fail;
- ph_world_set_player(world, player);
-
- return 0;
-
-fail:
- fprintf(stderr, "failed to initialize world\n");
- ph_area_free(&world->area);
- return -1;
-}
-#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)
+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;
@@ -258,7 +237,7 @@ ph_draw_items(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *wor
SDL_Texture *texture = NULL;
SDL_FRect rect;
- if (!drop->active) {
+ if (!drop->active || drop->area_id != world->area_id) {
continue;
}
@@ -298,7 +277,7 @@ ph_draw_entities(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *
int sprite_tile_y;
SDL_FlipMode flip;
- if (!entity->active || !def) {
+ if (!entity->active || entity->area_id != world->area_id || !def) {
continue;
}
@@ -583,11 +562,13 @@ ph_draw_inventory_popup(SDL_Renderer *renderer, const PhWorld *world,
item->name ? item->name : "ITEM");
SDL_RenderDebugText(renderer, x, 76.0f,
item->description ? item->description : "No description.");
- snprintf(text, sizeof(text), "VALUE %d", item->value);
+ 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;
@@ -651,7 +632,9 @@ ph_draw_inventory(SDL_Renderer *renderer, const PhAssets *assets,
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]) snprintf(text, sizeof(text), "VALUE %d", item->value);
+ 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);
}
@@ -715,6 +698,142 @@ ph_inventory_key(PhInventoryUi *ui, PhWorld *world, SDL_Scancode key)
}
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 {
+ SDL_RenderDebugText(renderer, 24.0f, 48.0f, shop->talk[0]);
+ SDL_RenderDebugText(renderer, 24.0f, 66.0f, shop->talk[1]);
+ SDL_RenderDebugText(renderer, 24.0f, 102.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)
{
@@ -872,14 +991,21 @@ ph_draw_battle_end(SDL_Renderer *renderer, const PhWorld *world,
}
static void
-ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, PhWorld *world,
- PhInput input, float dt, PhMenu menu, const PhInventoryUi *inventory_ui,
- PhGameMode *game, PhBattle *battle)
+ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets,
+ 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;
if (*game == PH_GAME_WORLD && 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);
*game = PH_GAME_TRANSITION;
@@ -911,6 +1037,8 @@ ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, PhWorld *world,
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,
@@ -996,8 +1124,11 @@ ph_run_render_smoke_test(void)
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;
@@ -1023,7 +1154,7 @@ ph_run_render_smoke_test(void)
return 1;
}
- if (ph_make_world(&world) < 0) {
+ if (ph_game_world_init(&world, (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) {
SDL_DestroyRenderer(renderer);
SDL_DestroySurface(surface);
SDL_Quit();
@@ -1074,10 +1205,17 @@ ph_run_render_smoke_test(void)
for (i = 0; i < 10; ++i) {
inventory_ui.mode = i == 8 ? PH_INVENTORY_EXAMINE :
i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE;
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- 1.0f / 60.0f, i == 7 ? PH_MENU_CHARACTER :
- i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE, &inventory_ui,
- &game, &battle);
+ 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_STONEHOLLOW);
+ 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);
@@ -1089,27 +1227,26 @@ ph_run_render_smoke_test(void)
} else {
world.encounter_index = enemy_index;
game = PH_GAME_WORLD;
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 0.0f,
- PH_MENU_NONE, &inventory_ui, &game, &battle);
+ context.menu = PH_MENU_NONE;
+ ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f);
if (game != PH_GAME_TRANSITION) result = 1;
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- PH_BATTLE_TRANSITION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
- &game, &battle);
+ 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;
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 0.0f,
- PH_MENU_NONE, &inventory_ui, &game, &battle);
+ 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;
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- PH_BATTLE_TRANSITION_SECONDS, PH_MENU_NONE, &inventory_ui,
- &game, &battle);
+ 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;
@@ -1134,15 +1271,13 @@ ph_run_render_smoke_test(void)
fprintf(stderr, "render smoke test failed: spell CTB order\n");
result = 1;
}
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 0.0f,
- PH_MENU_NONE, &inventory_ui, &game, &battle);
+ context.menu = PH_MENU_NONE;
+ ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f);
ph_battle_cast(&battle, &world);
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
- &game, &battle);
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
- &game, &battle);
+ 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 ||
@@ -1153,18 +1288,26 @@ ph_run_render_smoke_test(void)
}
battle.command = PH_BATTLE_FIGHT;
ph_battle_select(&battle, &world);
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
- &game, &battle);
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
- PH_BATTLE_ACTION_SECONDS * 0.5f, PH_MENU_NONE, &inventory_ui,
- &game, &battle);
+ 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;
+ world.entities[world.player_index].pos = (PhVec2){
+ PH_TILE_CENTER(29), PH_TILE_CENTER(2) };
+ if (ph_game_update_portal(&world) != 1) {
+ fprintf(stderr, "render smoke test failed: town portal\n");
+ result = 1;
+ }
+ ph_shop_open(&shop_ui, PH_SHOP_STONEHOLLOW);
+ context.menu = PH_MENU_NONE;
+ 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());
@@ -1187,8 +1330,11 @@ ph_run_game(int frame_limit)
PhAssets assets;
PhWorld world;
PhInventoryUi inventory_ui = { 0 };
+ PhShopUi shop_ui = { 0 };
PhGameMode game = PH_GAME_WORLD;
PhBattle battle = { 0 };
+ PhGameContext context = { &world, &inventory_ui, &shop_ui, &game, &battle,
+ PH_MENU_NONE };
Uint64 last_ticks;
int frame_count = 0;
int running = 1;
@@ -1199,7 +1345,7 @@ ph_run_game(int frame_limit)
return 1;
}
- if (ph_make_world(&world) < 0) {
+ if (ph_game_world_init(&world, (float)PH_VIEW_W, (float)PH_VIEW_H) < 0) {
SDL_Quit();
return 1;
}
@@ -1267,10 +1413,14 @@ ph_run_game(int frame_limit)
event.key.scancode == PH_KEY_MENU_CANCEL) {
menu = PH_MENU_NONE;
} else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
- game == PH_GAME_WORLD && event.key.scancode == PH_KEY_CHARACTER) {
+ game == PH_GAME_WORLD &&
+ (menu == PH_MENU_NONE || menu == PH_MENU_CHARACTER) &&
+ event.key.scancode == PH_KEY_CHARACTER) {
menu = menu == PH_MENU_CHARACTER ? PH_MENU_NONE : PH_MENU_CHARACTER;
} else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
- game == PH_GAME_WORLD && event.key.scancode == PH_KEY_INVENTORY) {
+ 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 {
@@ -1281,6 +1431,10 @@ ph_run_game(int frame_limit)
game == PH_GAME_WORLD && menu == PH_MENU_INVENTORY) {
if (ph_inventory_key(&inventory_ui, &world, event.key.scancode))
menu = PH_MENU_NONE;
+ } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
+ game == PH_GAME_WORLD && menu == PH_MENU_SHOP) {
+ if (ph_shop_key(&shop_ui, &world, event.key.scancode))
+ menu = PH_MENU_NONE;
}
}
@@ -1292,8 +1446,13 @@ ph_run_game(int frame_limit)
last_ticks = now_ticks;
input = ph_read_input(SDL_GetKeyboardState(NULL));
- ph_render_frame(renderer, &assets, &world, input, dt, menu, &inventory_ui,
- &game, &battle);
+ context.menu = menu;
+ ph_render_frame(renderer, &assets, &context, input, dt);
+ 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;
diff --git a/src/tests/smoke.c b/src/tests/smoke.c
@@ -1,4 +1,5 @@
#include "engine/world.h"
+#include "game/area.h"
#include "game/battle.h"
#include "game/content.h"
#include "config.h"
@@ -42,7 +43,7 @@ ph_movement_test(int open, int blocked)
for (x = 0; x < area.width; ++x)
if (!x || !y || x == area.width - 1 || y == area.height - 1)
tiles[y * area.width + x] = (unsigned char)blocked;
- ph_world_init(&world, area, (float)(area.width * PH_TILE_SIZE),
+ ph_world_init(&world, area, 1, (float)(area.width * PH_TILE_SIZE),
(float)(area.height * PH_TILE_SIZE));
if (ph_world_add_entity_def(&world, (PhEntityDef){
.id = 1, .stats = { .max_hp = 10 }, .move_speed = 2,
@@ -51,9 +52,9 @@ ph_movement_test(int open, int blocked)
.id = 2, .stats = { .max_hp = 10 }, .move_speed = 1,
.vision = 8, .aggressive = 1, .blocks_movement = 1,
.kind = PH_ENTITY_MONSTER }) < 0 ||
- (player_index = ph_world_spawn_entity(&world, 1, player_start, 0)) < 0 ||
+ (player_index = ph_world_spawn_entity(&world, 1, player_start, 0, 1)) < 0 ||
(mob_index = ph_world_spawn_entity(&world, 2, mob_start,
- TERRITORY)) < 0) return 1;
+ TERRITORY, 1)) < 0) return 1;
ph_world_set_player(&world, player_index);
mob = &world.entities[mob_index];
ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
@@ -126,7 +127,7 @@ ph_logic_test(int open, int blocked)
memset(tiles, blocked, sizeof(tiles));
tiles[ROW * area.width + LEFT_COL] = (unsigned char)open;
tiles[ROW * area.width + RIGHT_COL] = (unsigned char)open;
- ph_world_init(&world, area, (float)(area.width * PH_TILE_SIZE),
+ ph_world_init(&world, area, 1, (float)(area.width * PH_TILE_SIZE),
(float)(area.height * PH_TILE_SIZE));
if (ph_world_add_entity_def(&world, (PhEntityDef){
.id = 1, .name = "Tester", .stats = { .max_hp = 5, .max_mp = 5,
@@ -144,10 +145,10 @@ ph_logic_test(int open, int blocked)
.use = PH_ITEM_USE_HEAL, .use_power = 3 }) < 0 ||
ph_world_add_item_def(&world, (PhItemDef){ .id = 3,
.equip_slot = PH_EQUIP_WEAPON }) < 0 ||
- (player_index = ph_world_spawn_entity(&world, 1, player_start, 0)) < 0 ||
- (enemy_index = ph_world_spawn_entity(&world, 4, enemy_start, 0)) < 0 ||
- ph_world_drop_item(&world, 1, player_start, 1) < 0 ||
- ph_world_drop_item(&world, 2, player_start, 2) < 0) return 1;
+ (player_index = ph_world_spawn_entity(&world, 1, player_start, 0, 1)) < 0 ||
+ (enemy_index = ph_world_spawn_entity(&world, 4, enemy_start, 0, 1)) < 0 ||
+ ph_world_drop_item(&world, 1, player_start, 1, 1) < 0 ||
+ ph_world_drop_item(&world, 2, player_start, 2, 1) < 0) return 1;
ph_world_set_player(&world, player_index);
if (ph_world_equip_item(&world, player_index, 3) < 0) return 1;
ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
@@ -210,6 +211,76 @@ ph_logic_test(int open, int blocked)
return 0;
}
+static int
+ph_area_shop_test(void)
+{
+ PhWorld world;
+ PhEntity *player;
+ const PhItemDef *potion;
+ const PhItemDef *ether;
+ int potion_before;
+ int slime_found = 0;
+ int i;
+
+ if (ph_game_world_init(&world, 320.0f, 240.0f) < 0) return 1;
+ player = &world.entities[world.player_index];
+ potion = ph_world_item_def(&world, PH_ITEM_HEALING_POTION);
+ ether = ph_world_item_def(&world, PH_ITEM_ETHER);
+ if (!potion || !ether || world.area_id != PH_AREA_MEADOW || player->gold != 40)
+ goto fail;
+ player->pos = (PhVec2){ PH_TILE_CENTER(29), PH_TILE_CENTER(2) };
+ if (ph_game_update_portal(&world) != 1 ||
+ world.area_id != PH_AREA_STONEHOLLOW) goto fail;
+ for (i = 0; i < world.entity_count; ++i) {
+ const PhEntityDef *def = ph_world_entity_def(&world,
+ world.entities[i].type_id);
+
+ if (def && def->interaction_id == PH_SHOP_STONEHOLLOW) {
+ player->pos = (PhVec2){ world.entities[i].pos.x,
+ world.entities[i].pos.y + PH_TILE_SIZE };
+ player->facing_x = 0;
+ player->facing_y = -1;
+ break;
+ }
+ }
+ if (i == world.entity_count) goto fail;
+ ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
+ {
+ const PhInteractionDef *interaction =
+ ph_game_interaction_def(world.interaction_id);
+
+ if (!interaction || interaction->kind != PH_INTERACTION_SHOP ||
+ interaction->content_id != PH_SHOP_STONEHOLLOW) goto fail;
+ }
+ potion_before = ph_world_item_amount(&world, potion->id);
+ if (ph_world_buy_item(&world, world.player_index, potion->id) < 0 ||
+ player->gold != 40 - potion->buy_value ||
+ ph_world_item_amount(&world, potion->id) != potion_before + 1 ||
+ ph_world_sell_item(&world, world.player_index, potion->id) < 0 ||
+ player->gold != 40 - potion->buy_value + potion->sell_value) goto fail;
+ if (ph_world_buy_item(&world, world.player_index, ether->id) < 0) goto fail;
+ player->mp = 0;
+ if (ph_world_use_inventory_item(&world, world.player_index, ether->id) < 0 ||
+ player->mp != ether->use_power) goto fail;
+ player->pos = (PhVec2){ PH_TILE_CENTER(15), PH_TILE_CENTER(22) };
+ if (ph_game_update_portal(&world) != 1 || world.area_id != PH_AREA_MEADOW)
+ goto fail;
+ 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 &&
+ world.entities[i].area_id == PH_AREA_MEADOW &&
+ world.entities[i].active) slime_found = 1;
+ }
+ ph_area_free(&world.area);
+ return slime_found ? 0 : 1;
+
+fail:
+ ph_area_free(&world.area);
+ return 1;
+}
+
int
main(void)
{
@@ -233,5 +304,9 @@ main(void)
fprintf(stderr, "smoke test failed: world or battle logic\n");
return 1;
}
+ if (ph_area_shop_test()) {
+ fprintf(stderr, "smoke test failed: area or shop logic\n");
+ return 1;
+ }
return 0;
}