commit 30803d85bd089d287bb0aa04aaaacbb42f814c65
parent 22c1f0882a2850f569b6c94299736cf2f99162ef
Author: beep <beep@wimdupont.com>
Date: Wed, 29 Jul 2026 13:50:43 +0000
Add autonomous animated world actors
Diffstat:
10 files changed, 530 insertions(+), 194 deletions(-)
diff --git a/Makefile b/Makefile
@@ -1,4 +1,5 @@
BIN = bin/phantasia
+SMOKE = bin/ph-smoke
MAPC = bin/ph-mapc
OBJDIR = obj
MAPDIR = $(OBJDIR)/maps
@@ -23,7 +24,9 @@ MAPC_SRC = src/tools/mapc.c src/game/content.c
CONFIG = config.h
OBJ = $(SRC:src/%.c=$(OBJDIR)/%.o)
-DEP = $(OBJ:.o=.d)
+SMOKE_OBJ = $(OBJDIR)/tests/smoke.o
+CORE_OBJ = $(filter-out $(OBJDIR)/game/main.o,$(OBJ))
+DEP = $(OBJ:.o=.d) $(SMOKE_OBJ:.o=.d)
SDL3_CFLAGS = $(shell pkg-config --cflags sdl3 2>/dev/null)
SDL3_LDLIBS = $(shell pkg-config --libs sdl3 2>/dev/null)
@@ -48,6 +51,10 @@ $(BIN): $(OBJ) $(MAPBIN) $(TILESET_BMP) $(HERO_BMP) $(ITEMS_BMP) $(CRITTERS_BMP)
@mkdir -p $(dir $@)
$(CC) $(OBJ) -o $@ $(LDLIBS)
+$(SMOKE): $(CORE_OBJ) $(SMOKE_OBJ)
+ @mkdir -p $(dir $@)
+ $(CC) $^ -o $@ -lm
+
$(MAPC): $(MAPC_SRC) $(CONFIG)
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) $(MAPC_SRC) -o $@
@@ -56,7 +63,7 @@ $(OBJDIR)/%.o: src/%.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
-$(OBJ): $(CONFIG)
+$(OBJ) $(SMOKE_OBJ): $(CONFIG)
$(CONFIG):
cp config.def.h $@
@@ -81,8 +88,8 @@ $(CRITTERS_BMP): $(CRITTERS_SRC)
@mkdir -p $(dir $@)
magick $< -alpha off BMP3:$@
-smoke: $(BIN)
- $(BIN) --smoke-test
+smoke: $(SMOKE)
+ $(SMOKE)
render-smoke: $(BIN)
SDL_VIDEODRIVER=dummy $(BIN) --render-smoke-test
diff --git a/README.adoc b/README.adoc
@@ -13,7 +13,16 @@
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 and can be exercised by the headless build.
+battle state machine. Headless world and battle coverage lives separately in
+`src/tests/smoke.c`; production `main.c` contains no logic-test fixtures.
+
+World actors update even while the player stands still. Entity content defines
+relative movement speed, directional animation frames, vision, and aggression;
+spawn content defines each actor's home and wandering radius. Aggressive actors
+with clear sight pursue the player, remember the last visible position briefly,
+then return to their territory. Movement and sight both respect blocked map
+tiles, while reserved destinations keep moving entities from crossing through
+one another.
== Principles
@@ -57,6 +66,9 @@ radii and timing, paths, colors, and controls. Concrete game content—assets,
tiles, entities, items, spells, starting equipment, spawns, drops, and rewards—
lives in the typed `src/game/content.c`/`content.h` module. Adding content should
extend those data tables without adding renderer or initialization special cases.
+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`.
The SDL window is runtime-resizable. Rendering stays on the configured logical
canvas and uses integer presentation scaling; right-, bottom-, and center-aligned
diff --git a/config.def.h b/config.def.h
@@ -1,7 +1,7 @@
#ifndef CONFIG_H
#define CONFIG_H
-#define PH_CONFIG_VERSION 5
+#define PH_CONFIG_VERSION 6
#define PH_VIEW_W 320
#define PH_VIEW_H 240
@@ -13,6 +13,10 @@
#define PH_ENTITY_BLOCK_RADIUS 10
#define PH_PICKUP_RADIUS 18.0f
#define PH_NOTICE_SECONDS 1.25f
+#define PH_WORLD_MOVE_PIXELS_PER_SECOND 45
+#define PH_WORLD_ACTION_MILLISECONDS 500
+#define PH_MOB_MEMORY_TURNS 4
+#define PH_MOB_WANDER_PERCENT 60
#define PH_BATTLE_TRANSITION_SECONDS 0.55f
#define PH_BATTLE_ACTION_SECONDS 0.50f
#define PH_BATTLE_CTB_BASE 1000
diff --git a/phantasia.6 b/phantasia.6
@@ -6,12 +6,12 @@
.Nd 2D SDL3 RPG prototype
.Sh SYNOPSIS
.Nm
-.Op Fl -smoke-test
.Op Fl -render-smoke-test
.Sh DESCRIPTION
.Nm
is a small top-down RPG prototype with map movement, item pickup, visible
-in-world encounters, and turn-based battles.
+in-world encounters, independently roaming and pursuing monsters, and turn-based
+battles.
.Sh KEYBINDINGS
.Bl -tag -width "Arrow keys / WASD"
.It Arrow keys / WASD
@@ -33,8 +33,6 @@ Quit the game.
.El
.Sh OPTIONS
.Bl -tag -width Ds
-.It Fl -smoke-test
-Run a headless world-update smoke test.
.It Fl -render-smoke-test
Run a headless software-render smoke test and save a frame dump to
.Pa obj/render-smoke.bmp .
diff --git a/src/engine/world.c b/src/engine/world.c
@@ -12,6 +12,12 @@
#error "combat formula divisors must be positive"
#endif
+#if PH_WORLD_MOVE_PIXELS_PER_SECOND <= 0 || PH_WORLD_ACTION_MILLISECONDS <= 0 || \
+ PH_MOB_MEMORY_TURNS < 0 || \
+ PH_MOB_WANDER_PERCENT < 0 || PH_MOB_WANDER_PERCENT > 100
+#error "invalid world movement configuration"
+#endif
+
typedef struct {
uint32_t magic;
uint32_t version;
@@ -20,13 +26,6 @@ typedef struct {
char name[PH_AREA_NAME_MAX];
} PhMapHeader;
-static PhVec2
-ph_vec2(float x, float y)
-{
- PhVec2 v = { x, y };
- return v;
-}
-
static float
ph_clampf(float value, float min, float max)
{
@@ -151,32 +150,44 @@ ph_world_blocking_entity(const PhWorld *world, int self_index, PhVec2 pos)
(float)(PH_ENTITY_BLOCK_RADIUS * PH_ENTITY_BLOCK_RADIUS)) {
return entity;
}
+ if (entity->moving && ph_dist2(entity->move_target, pos) <
+ (float)(PH_ENTITY_BLOCK_RADIUS * PH_ENTITY_BLOCK_RADIUS)) {
+ return entity;
+ }
}
return NULL;
}
static int
-ph_player_step_blocked(PhWorld *world, int player_index, PhVec2 pos)
+ph_entity_step_blocked(PhWorld *world, int entity_index, PhVec2 pos)
{
const PhEntity *blocker;
+ const PhEntityDef *mover;
const PhEntityDef *def;
if (ph_position_blocked(world, pos)) {
return 1;
}
- blocker = ph_world_blocking_entity(world, player_index, pos);
+ blocker = ph_world_blocking_entity(world, entity_index, pos);
if (!blocker) {
return 0;
}
+ mover = ph_world_entity_def(world, world->entities[entity_index].type_id);
def = ph_world_entity_def(world, blocker->type_id);
- if (def && def->kind == PH_ENTITY_MONSTER) {
+ if (mover && def && mover->kind == PH_ENTITY_PLAYER &&
+ def->kind == PH_ENTITY_MONSTER) {
world->encounter_index = (int)(blocker - world->entities);
return 1;
}
- if (def && def->name) {
+ if (mover && def && mover->kind == PH_ENTITY_MONSTER &&
+ def->kind == PH_ENTITY_PLAYER) {
+ world->encounter_index = entity_index;
+ return 1;
+ }
+ if (mover && mover->kind == PH_ENTITY_PLAYER && def && def->name) {
char text[PH_NOTICE_SIZE];
snprintf(text, sizeof(text), "%s blocks your path.", def->name);
@@ -220,6 +231,171 @@ ph_orthogonal_input(PhInput input)
return input;
}
+static int
+ph_area_line_clear(const PhArea *area, PhVec2 from, PhVec2 to)
+{
+ int x = (int)floorf(from.x / PH_TILE_SIZE);
+ int y = (int)floorf(from.y / PH_TILE_SIZE);
+ int end_x = (int)floorf(to.x / PH_TILE_SIZE);
+ int end_y = (int)floorf(to.y / PH_TILE_SIZE);
+ int dx = abs(end_x - x);
+ int dy = -abs(end_y - y);
+ int step_x = x < end_x ? 1 : -1;
+ int step_y = y < end_y ? 1 : -1;
+ int error = dx + dy;
+
+ for (;;) {
+ if (ph_area_tile_blocked(area, x, y)) return 0;
+ if (x == end_x && y == end_y) return 1;
+ if (error * 2 >= dy) {
+ error += dy;
+ x += step_x;
+ }
+ if (error * 2 <= dx) {
+ error += dx;
+ y += step_y;
+ }
+ }
+}
+
+static int
+ph_actor_sees_player(const PhWorld *world, const PhEntity *actor,
+ const PhEntityDef *def)
+{
+ const PhEntity *player = ph_world_player(world);
+ float range = (float)(def->vision * PH_TILE_SIZE);
+
+ return def->aggressive && def->vision > 0 && player && player->active &&
+ ph_dist2(actor->pos, player->pos) <= range * range &&
+ ph_area_line_clear(&world->area, actor->pos, player->pos);
+}
+
+static int
+ph_entity_begin_step(PhWorld *world, int entity_index, int dx, int dy,
+ int stay_in_territory)
+{
+ PhEntity *entity = &world->entities[entity_index];
+ PhVec2 target = {
+ entity->pos.x + (float)(dx * PH_TILE_SIZE),
+ entity->pos.y + (float)(dy * PH_TILE_SIZE),
+ };
+ float radius = (float)(entity->territory_radius * PH_TILE_SIZE);
+
+ if ((!dx && !dy) || entity->moving) return 0;
+ if (stay_in_territory && entity->territory_radius > 0 &&
+ ph_dist2(target, entity->home) > radius * radius) return 0;
+ if (ph_entity_step_blocked(world, entity_index, target)) return 0;
+ entity->facing_x = dx;
+ entity->facing_y = dy;
+ entity->move_target = target;
+ entity->moving = 1;
+ return 1;
+}
+
+static int
+ph_entity_step_toward(PhWorld *world, int entity_index, PhVec2 target)
+{
+ PhEntity *entity = &world->entities[entity_index];
+ float dx = target.x - entity->pos.x;
+ float dy = target.y - entity->pos.y;
+ int sx = dx < 0.0f ? -1 : dx > 0.0f ? 1 : 0;
+ int sy = dy < 0.0f ? -1 : dy > 0.0f ? 1 : 0;
+
+ if (fabsf(dx) >= fabsf(dy)) {
+ if (ph_entity_begin_step(world, entity_index, sx, 0, 0)) return 1;
+ return ph_entity_begin_step(world, entity_index, 0, sy, 0);
+ }
+ if (ph_entity_begin_step(world, entity_index, 0, sy, 0)) return 1;
+ return ph_entity_begin_step(world, entity_index, sx, 0, 0);
+}
+
+static void
+ph_world_actor_action(PhWorld *world, int entity_index)
+{
+ PhEntity *actor = &world->entities[entity_index];
+ const PhEntityDef *def = ph_world_entity_def(world, actor->type_id);
+ int direction;
+
+ if (!def || def->kind == PH_ENTITY_PLAYER || !actor->active || actor->hp <= 0)
+ return;
+ if (ph_actor_sees_player(world, actor, def)) {
+ actor->last_seen = ph_world_player(world)->pos;
+ actor->chase_turns = PH_MOB_MEMORY_TURNS;
+ ph_entity_step_toward(world, entity_index, actor->last_seen);
+ return;
+ }
+ if (actor->chase_turns > 0) {
+ --actor->chase_turns;
+ ph_entity_step_toward(world, entity_index, actor->last_seen);
+ return;
+ }
+ if (actor->territory_radius > 0) {
+ float radius = (float)(actor->territory_radius * PH_TILE_SIZE);
+
+ if (ph_dist2(actor->pos, actor->home) > radius * radius) {
+ ph_entity_step_toward(world, entity_index, actor->home);
+ return;
+ }
+ }
+ if ((int)(ph_world_random(world) % 100u) >= PH_MOB_WANDER_PERCENT) return;
+ direction = (int)(ph_world_random(world) % 4u);
+ ph_entity_begin_step(world, entity_index,
+ direction == 0 ? -1 : direction == 1 ? 1 : 0,
+ direction == 2 ? -1 : direction == 3 ? 1 : 0, 1);
+}
+
+static void
+ph_world_advance_movers(PhWorld *world, float dt)
+{
+ int i;
+
+ for (i = 0; i < world->entity_count; ++i) {
+ PhEntity *entity = &world->entities[i];
+ const PhEntityDef *def;
+ float dx;
+ float dy;
+ float distance;
+ float travel;
+
+ if (i == world->player_index || !entity->active || !entity->moving) 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;
+ dy = entity->move_target.y - entity->pos.y;
+ distance = sqrtf(dx * dx + dy * dy);
+ travel = (float)(PH_WORLD_MOVE_PIXELS_PER_SECOND * def->move_speed) * dt;
+ if (distance <= travel || distance <= 0.001f) {
+ entity->pos = entity->move_target;
+ entity->moving = 0;
+ entity->animation_time = 0.0f;
+ } else {
+ entity->pos.x += dx / distance * travel;
+ entity->pos.y += dy / distance * travel;
+ entity->animation_time += dt;
+ }
+ }
+}
+
+static void
+ph_world_update_actors(PhWorld *world, float dt)
+{
+ float action_seconds = (float)PH_WORLD_ACTION_MILLISECONDS / 1000.0f;
+ int i;
+
+ for (i = 0; i < world->entity_count && world->encounter_index < 0; ++i) {
+ PhEntity *entity = &world->entities[i];
+ 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->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;
+ entity->move_credit -= action_seconds;
+ ph_world_actor_action(world, i);
+ }
+}
+
void
ph_world_init(PhWorld *world, PhArea area, float viewport_w, float viewport_h)
{
@@ -320,7 +496,8 @@ ph_world_add_item_def(PhWorld *world, PhItemDef def)
}
int
-ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos)
+ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos,
+ int territory_radius)
{
PhEntity *entity;
const PhEntityDef *def;
@@ -338,10 +515,14 @@ ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos)
memset(entity, 0, sizeof(*entity));
entity->type_id = type_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->facing_x = 0;
entity->facing_y = 1;
+ entity->territory_radius = territory_radius > 0 ? territory_radius : 0;
entity->active = 1;
return world->entity_count++;
@@ -575,11 +756,30 @@ ph_world_finish_encounter(PhWorld *world, int enemy_index)
return 0;
}
+static void
+ph_world_move_player_axis(PhWorld *world, PhEntity *player, float amount, int x_axis)
+{
+ float remaining = fabsf(amount);
+ float direction = amount < 0.0f ? -1.0f : 1.0f;
+
+ while (remaining > 0.0f) {
+ float distance = remaining < 1.0f ? remaining : 1.0f;
+ PhVec2 next = player->pos;
+
+ if (x_axis) next.x += direction * distance;
+ else next.y += direction * distance;
+ if (ph_entity_step_blocked(world, world->player_index, next)) break;
+ player->pos = next;
+ remaining -= distance;
+ }
+}
+
void
ph_world_tick(PhWorld *world, PhInput input, float dt)
{
PhEntity *player;
const PhEntityDef *def;
+ PhVec2 before;
PhVec2 next;
if (world->player_index < 0 || world->player_index >= world->entity_count) {
@@ -591,6 +791,7 @@ ph_world_tick(PhWorld *world, PhInput input, float dt)
if (!player->active || !def) {
return;
}
+ ph_world_advance_movers(world, dt);
if (world->notice_seconds > 0.0f) {
world->notice_seconds -= dt;
@@ -601,29 +802,28 @@ ph_world_tick(PhWorld *world, PhInput input, float dt)
}
input = ph_orthogonal_input(input);
+ before = player->pos;
next = player->pos;
- if (input.move_x != 0 || input.move_y != 0) {
- float speed = (float)def->move_speed;
+ if ((input.move_x != 0 || input.move_y != 0) && def->move_speed > 0) {
+ float speed = (float)(PH_WORLD_MOVE_PIXELS_PER_SECOND * def->move_speed);
player->facing_x = input.move_x;
player->facing_y = input.move_y;
next.x += (float)input.move_x * speed * dt;
next.y += (float)input.move_y * speed * dt;
- if (!ph_player_step_blocked(world, world->player_index,
- ph_vec2(next.x, player->pos.y))) {
- player->pos.x = next.x;
- }
- if (!ph_player_step_blocked(world, world->player_index,
- ph_vec2(player->pos.x, next.y))) {
- player->pos.y = next.y;
- }
+ ph_world_move_player_axis(world, player, next.x - player->pos.x, 1);
+ ph_world_move_player_axis(world, player, next.y - player->pos.y, 0);
}
+ player->moving = ph_dist2(before, player->pos) > 0.0001f;
+ if (player->moving) player->animation_time += dt;
+ else player->animation_time = 0.0f;
if (input.interact) {
ph_try_pickup(world, player);
}
+ ph_world_update_actors(world, dt);
ph_camera_follow_player(world);
}
diff --git a/src/engine/world.h b/src/engine/world.h
@@ -3,7 +3,7 @@
#include <stddef.h>
-#define PH_CONFIG_API_VERSION 5
+#define PH_CONFIG_API_VERSION 6
enum {
PH_MAP_MAGIC = 0x50484d50,
@@ -15,8 +15,11 @@ enum {
PH_MAX_ENTITIES = 128,
PH_MAX_GROUND_ITEMS = 128,
PH_NOTICE_SIZE = 96,
+ PH_ANIMATION_FRAMES = 4,
};
+#define PH_TILE_CENTER(tile) ((float)((tile) * PH_TILE_SIZE + PH_TILE_SIZE / 2))
+
typedef enum {
PH_EQUIP_NONE,
PH_EQUIP_WEAPON,
@@ -74,11 +77,15 @@ typedef struct {
const char *name;
PhStats stats;
int move_speed;
+ int vision;
+ int aggressive;
int xp_reward;
int loot_item_id;
int loot_amount;
int asset_id;
- int sprite_tiles[PH_SPRITE_DIRECTIONS][2];
+ int sprite_tiles[PH_SPRITE_DIRECTIONS][PH_ANIMATION_FRAMES][2];
+ int animation_frames;
+ float animation_fps;
int blocks_movement;
PhEntityKind kind;
} PhEntityDef;
@@ -100,6 +107,9 @@ typedef struct {
typedef struct {
int type_id;
PhVec2 pos;
+ PhVec2 move_target;
+ PhVec2 home;
+ PhVec2 last_seen;
int hp;
int mp;
int xp;
@@ -107,6 +117,11 @@ typedef struct {
int accuracy_turns;
int facing_x;
int facing_y;
+ int territory_radius;
+ int chase_turns;
+ int moving;
+ float move_credit;
+ float animation_time;
int equipment[PH_EQUIP_COUNT];
int active;
} PhEntity;
@@ -165,7 +180,8 @@ void ph_area_free(PhArea *area);
void ph_world_init(PhWorld *world, PhArea area, float viewport_w, float viewport_h);
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 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);
void ph_world_set_player(PhWorld *world, int entity_index);
int ph_world_equip_item(PhWorld *world, int entity_index, int item_id);
diff --git a/src/game/content.c b/src/game/content.c
@@ -25,9 +25,15 @@ const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT] = {
.stats = { .max_hp = 40, .max_mp = 50, .strength = 5, .defense = 3,
.magic = 4, .magic_defense = 3, .agility = 10, .luck = 5,
.evasion = 2, .accuracy = 100 },
- .move_speed = 90,
+ .move_speed = 2,
.asset_id = PH_ASSET_HERO,
- .sprite_tiles = { { 4, 0 }, { 0, 0 }, { 0, 1 } },
+ .sprite_tiles = {
+ [PH_SPRITE_DOWN] = { { 4, 0 }, { 6, 0 } },
+ [PH_SPRITE_UP] = { { 0, 0 }, { 2, 0 } },
+ [PH_SPRITE_SIDE] = { { 0, 1 }, { 2, 1 } },
+ },
+ .animation_frames = 2,
+ .animation_fps = 8.0f,
.blocks_movement = 0,
.kind = PH_ENTITY_PLAYER,
},
@@ -37,12 +43,20 @@ const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT] = {
.stats = { .max_hp = 12, .strength = 3, .defense = 1,
.magic_defense = 1, .agility = 10, .luck = 1,
.evasion = 2, .accuracy = 100 },
- .move_speed = 45,
+ .move_speed = 1,
+ .vision = 6,
+ .aggressive = 1,
.xp_reward = 8,
.loot_item_id = PH_ITEM_HEALING_POTION,
.loot_amount = 1,
.asset_id = PH_ASSET_CRITTERS,
- .sprite_tiles = { { 12, 0 }, { 12, 0 }, { 12, 0 } },
+ .sprite_tiles = {
+ [PH_SPRITE_DOWN] = { { 12, 0 }, { 13, 0 }, { 14, 0 }, { 15, 0 } },
+ [PH_SPRITE_UP] = { { 12, 0 }, { 13, 0 }, { 14, 0 }, { 15, 0 } },
+ [PH_SPRITE_SIDE] = { { 12, 0 }, { 13, 0 }, { 14, 0 }, { 15, 0 } },
+ },
+ .animation_frames = 4,
+ .animation_fps = 8.0f,
.blocks_movement = 1,
.kind = PH_ENTITY_MONSTER,
},
@@ -101,11 +115,11 @@ const int ph_player_equipment[PH_EQUIP_COUNT] = {
};
const PhEntitySpawn ph_entity_spawns[PH_ENTITY_SPAWN_COUNT] = {
- { PH_DEF_PLAYER, { 80.0f, 80.0f } },
- { PH_DEF_SLIME, { 180.0f, 120.0f } },
+ { PH_DEF_PLAYER, { PH_TILE_CENTER(5), PH_TILE_CENTER(4) }, 0 },
+ { PH_DEF_SLIME, { PH_TILE_CENTER(11), PH_TILE_CENTER(7) }, 5 },
};
const PhItemDrop ph_item_drops[PH_ITEM_DROP_COUNT] = {
- { PH_ITEM_RUSTED_SPEAR, { 104.0f, 80.0f }, 1 },
- { PH_ITEM_HEALING_POTION, { 104.0f, 96.0f }, 1 },
+ { 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 },
};
diff --git a/src/game/content.h b/src/game/content.h
@@ -13,6 +13,7 @@ typedef struct {
typedef struct {
int type_id;
PhVec2 pos;
+ int territory_radius;
} PhEntitySpawn;
typedef struct {
diff --git a/src/game/main.c b/src/game/main.c
@@ -106,7 +106,7 @@ ph_make_world(PhWorld *world)
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].pos, ph_entity_spawns[i].territory_radius);
if (entity < 0) goto fail;
if (i == PH_PLAYER_SPAWN) player = entity;
}
@@ -128,146 +128,6 @@ fail:
}
#endif
-static int
-ph_run_smoke_test(void)
-{
- unsigned char tiles[12];
- PhArea area = {
- .name = "Smoke", .width = 4, .height = 3, .tiles = tiles,
- .tile_defs = ph_tile_defs, .tile_def_count = (int)LEN(ph_tile_defs),
- };
- PhWorld world;
- PhBattle battle;
- const PhEntity *player;
- PhStats stats;
- int order[PH_BATTLE_ORDER_COUNT];
- int open = -1;
- int blocked = -1;
- size_t tile;
- int player_index;
- int enemy_index;
- int i;
-
- for (tile = 0; tile < LEN(ph_tile_defs); ++tile) {
- if (ph_tile_defs[tile].blocks_movement) blocked = (int)tile;
- else open = (int)tile;
- }
- if (open < 0 || blocked < 0) {
- fprintf(stderr, "smoke test failed: tile configuration\n");
- return 1;
- }
- memset(tiles, blocked, sizeof(tiles));
- tiles[5] = tiles[6] = (unsigned char)open;
- ph_world_init(&world, area, 64.0f, 48.0f);
- if (ph_world_add_entity_def(&world, (PhEntityDef){
- .id = 1, .name = "Tester", .stats = { .max_hp = 5, .max_mp = 5,
- .strength = 4, .magic = 3, .accuracy = 100, .agility = 1 },
- .move_speed = 1,
- .kind = PH_ENTITY_PLAYER }) < 0 ||
- ph_world_add_entity_def(&world, (PhEntityDef){ .id = 4, .name = "Target",
- .stats = { .max_hp = 8, .strength = 2, .defense = 1,
- .accuracy = 100 }, .xp_reward = 7, .loot_item_id = 2,
- .loot_amount = 1, .blocks_movement = 1,
- .kind = PH_ENTITY_MONSTER }) < 0 ||
- ph_world_add_item_def(&world, (PhItemDef){
- .id = 1, .bonuses = { .max_hp = 2, .strength = 2 },
- .equip_slot = PH_EQUIP_WEAPON }) < 0 ||
- ph_world_add_item_def(&world, (PhItemDef){
- .id = 2, .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,
- (PhVec2){ 24.0f, 24.0f })) < 0 ||
- (enemy_index = ph_world_spawn_entity(&world, 4,
- (PhVec2){ 1000.0f, 1000.0f })) < 0 ||
- ph_world_drop_item(&world, 1, (PhVec2){ 24.0f, 24.0f }, 1) < 0 ||
- ph_world_drop_item(&world, 2, (PhVec2){ 24.0f, 24.0f }, 2) < 0) {
- fprintf(stderr, "smoke test failed: setup\n");
- return 1;
- }
- ph_world_set_player(&world, player_index);
- if (ph_world_equip_item(&world, player_index, 3) < 0) {
- fprintf(stderr, "smoke test failed: starting equipment\n");
- return 1;
- }
- ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
- if (ph_world_equip_inventory_item(&world, player_index, 1) < 0) {
- fprintf(stderr, "smoke test failed: inventory equipment\n");
- return 1;
- }
- world.entities[player_index].hp = 1;
- if (ph_world_use_inventory_item(&world, player_index, 2) < 0 ||
- ph_world_drop_inventory_item(&world, player_index, 2) < 0) {
- fprintf(stderr, "smoke test failed: inventory item action\n");
- return 1;
- }
-
- for (i = 0; i < 30; ++i) {
- ph_world_tick(&world, (PhInput){ .move_x = 1 }, 1.0f);
- }
- ph_world_tick(&world, (PhInput){ .move_x = 1, .move_y = 1 }, 1.0f);
-
- player = ph_world_player(&world);
- stats = ph_world_entity_stats(&world, player);
- if (!player || player->pos.x != 47.0f || player->pos.y != 25.0f ||
- world.camera.pos.x != 0.0f || world.camera.pos.y != 0.0f ||
- ph_world_item_amount(&world, 1) != 0 ||
- ph_world_item_amount(&world, 2) != 0 ||
- ph_world_item_amount(&world, 3) != 1 || player->hp != 4 ||
- stats.max_hp != 7 || stats.strength != 6 ||
- player->equipment[PH_EQUIP_WEAPON] != 1 ||
- !world.ground_items[0].active || world.ground_items[0].item_id != 2) {
- fprintf(stderr, "smoke test failed: unexpected world state\n");
- return 1;
- }
- world.entities[enemy_index].pos = (PhVec2){ player->pos.x - 5.0f, player->pos.y };
- ph_world_tick(&world, (PhInput){ .move_x = -1 }, 1.0f);
- ph_battle_begin(&battle, &world, enemy_index);
- if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) !=
- PH_BATTLE_READY) {
- fprintf(stderr, "smoke test failed: battle transition\n");
- return 1;
- }
- battle.command = PH_BATTLE_FIGHT;
- ph_battle_order(&battle, &world, order);
- if (order[0] != player_index || order[1] != enemy_index) {
- fprintf(stderr, "smoke test failed: CTB order\n");
- return 1;
- }
- battle.command = PH_BATTLE_MAGICK;
- ph_battle_select(&battle, &world);
- if (battle.phase != PH_BATTLE_MAGIC || world.entities[player_index].mp != 5) {
- fprintf(stderr, "smoke test failed: Magick submenu\n");
- return 1;
- }
- if (world.encounter_index != enemy_index ||
- ph_world_apply_accuracy_penalty(&world, enemy_index, 20, 3) < 0 ||
- world.entities[enemy_index].accuracy_penalty != 20 ||
- world.entities[enemy_index].accuracy_turns != 3) {
- fprintf(stderr, "smoke test failed: battle effect setup\n");
- return 1;
- }
- ph_world_advance_effects(&world, enemy_index);
- ph_world_advance_effects(&world, enemy_index);
- ph_world_advance_effects(&world, enemy_index);
- if (world.entities[enemy_index].accuracy_penalty != 0 ||
- world.entities[enemy_index].accuracy_turns != 0 ||
- ph_world_physical_attack(&world, player_index, enemy_index) <= 0 ||
- ph_world_magic_attack(&world, player_index, enemy_index, 2, 4) <= 0 ||
- world.entities[enemy_index].hp != 0 ||
- ph_world_finish_encounter(&world, enemy_index) < 0 ||
- player->xp != 7 || player->mp != 3 ||
- ph_world_item_amount(&world, 2) != 1 || world.encounter_index != -1) {
- fprintf(stderr, "smoke test failed: battle state\n");
- return 1;
- }
-
- printf("smoke player=(%.1f,%.1f) hp=%d equipment=%d xp=%d\n",
- player->pos.x, player->pos.y, player->hp,
- player->equipment[PH_EQUIP_WEAPON], player->xp);
- return 0;
-}
-
#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)
@@ -338,9 +198,13 @@ ph_entity_sprite_frame(const PhEntityDef *def, const PhEntity *entity,
{
int direction = entity->facing_y > 0 ? PH_SPRITE_DOWN :
entity->facing_y < 0 ? PH_SPRITE_UP : PH_SPRITE_SIDE;
+ int frames = def->animation_frames > 0 &&
+ def->animation_frames <= PH_ANIMATION_FRAMES ? def->animation_frames : 1;
+ int frame = entity->moving && def->animation_fps > 0.0f ?
+ (int)(entity->animation_time * def->animation_fps) % frames : 0;
- *sprite_tile_x = def->sprite_tiles[direction][0];
- *sprite_tile_y = def->sprite_tiles[direction][1];
+ *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;
}
@@ -538,7 +402,8 @@ ph_draw_character_sheet(SDL_Renderer *renderer, const PhAssets *assets,
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], def->sprite_tiles[PH_SPRITE_DOWN][1],
+ 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);
@@ -853,8 +718,8 @@ ph_draw_battle_entity(SDL_Renderer *renderer, const PhAssets *assets,
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];
- y = def->sprite_tiles[PH_SPRITE_SIDE][1];
+ 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);
@@ -1423,17 +1288,15 @@ ph_run_game(int frame_limit)
int
main(int argc, char **argv)
{
- if (argc > 1 && strcmp(argv[1], "--smoke-test") == 0) {
- return ph_run_smoke_test();
- }
-
#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);
#else
- fprintf(stderr, "SDL3 not available in this build; run `%s --smoke-test`\n", argv[0]);
+ (void)argc;
+ (void)argv;
+ fprintf(stderr, "SDL3 not available in this build; run `make smoke`\n");
return 1;
#endif
}
diff --git a/src/tests/smoke.c b/src/tests/smoke.c
@@ -0,0 +1,221 @@
+#include "engine/world.h"
+#include "game/battle.h"
+#include "game/content.h"
+#include "config.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#define LEN(a) (sizeof(a) / sizeof(0[a]))
+
+static int
+ph_movement_test(int open, int blocked)
+{
+ enum {
+ MAP_W = 9,
+ MAP_H = 5,
+ ROW = 2,
+ PLAYER_COL = 1,
+ MOB_COL = 6,
+ WALL_COL = 5,
+ TERRITORY = 2,
+ WANDER_ACTIONS = 20,
+ };
+ unsigned char tiles[MAP_W * MAP_H];
+ PhArea area = { .name = "Movement", .width = MAP_W, .height = MAP_H,
+ .tiles = tiles, .tile_defs = ph_tile_defs,
+ .tile_def_count = (int)LEN(ph_tile_defs) };
+ PhVec2 player_start = { PH_TILE_CENTER(PLAYER_COL), PH_TILE_CENTER(ROW) };
+ PhVec2 mob_start = { PH_TILE_CENTER(MOB_COL), PH_TILE_CENTER(ROW) };
+ float action_seconds = (float)PH_WORLD_ACTION_MILLISECONDS / 1000.0f;
+ PhWorld world;
+ PhEntity *mob;
+ int player_index;
+ int mob_index;
+ int moved = 0;
+ int x;
+ int y;
+ int i;
+
+ memset(tiles, open, sizeof(tiles));
+ for (y = 0; y < area.height; ++y)
+ 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),
+ (float)(area.height * PH_TILE_SIZE));
+ if (ph_world_add_entity_def(&world, (PhEntityDef){
+ .id = 1, .stats = { .max_hp = 10 }, .move_speed = 2,
+ .kind = PH_ENTITY_PLAYER }) < 0 ||
+ ph_world_add_entity_def(&world, (PhEntityDef){
+ .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 ||
+ (mob_index = ph_world_spawn_entity(&world, 2, mob_start,
+ TERRITORY)) < 0) return 1;
+ ph_world_set_player(&world, player_index);
+ mob = &world.entities[mob_index];
+ ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
+ if (!mob->moving || mob->move_target.x != PH_TILE_CENTER(MOB_COL - 1) ||
+ mob->move_target.y != mob_start.y) return 1;
+ ph_world_tick(&world, (PhInput){ 0 }, PH_MAX_FRAME_TIME);
+ if (mob->pos.x >= mob_start.x || mob->animation_time <= 0.0f) return 1;
+ ph_world_tick(&world, (PhInput){ .move_x = 1 }, PH_MAX_FRAME_TIME);
+ if (!world.entities[player_index].moving ||
+ world.entities[player_index].animation_time <= 0.0f) return 1;
+
+ world.entities[player_index].pos = player_start;
+ mob->pos = mob->move_target = mob_start;
+ mob->last_seen = player_start;
+ mob->moving = 0;
+ mob->move_credit = 0.0f;
+ mob->chase_turns = 2;
+ tiles[ROW * area.width + WALL_COL] = (unsigned char)blocked;
+ ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
+ if (mob->moving || mob->chase_turns != 1) return 1;
+
+ tiles[ROW * area.width + WALL_COL] = (unsigned char)open;
+ world.entity_defs[1].aggressive = 0;
+ mob->home = mob->pos = mob->move_target = mob_start;
+ mob->territory_radius = 1;
+ mob->chase_turns = 0;
+ mob->move_credit = 0.0f;
+ for (i = 0; i < WANDER_ACTIONS; ++i) {
+ float dx;
+ float dy;
+ float radius = (float)(mob->territory_radius * PH_TILE_SIZE);
+
+ ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
+ dx = mob->pos.x - mob->home.x;
+ dy = mob->pos.y - mob->home.y;
+ if (dx != 0.0f || dy != 0.0f) moved = 1;
+ if (dx * dx + dy * dy > radius * radius) return 1;
+ }
+ return moved ? 0 : 1;
+}
+
+static int
+ph_logic_test(int open, int blocked)
+{
+ enum { MAP_W = 4, MAP_H = 3, ROW = 1, LEFT_COL = 1, RIGHT_COL = 2 };
+ unsigned char tiles[MAP_W * MAP_H];
+ PhArea area = { .name = "Smoke", .width = MAP_W, .height = MAP_H,
+ .tiles = tiles, .tile_defs = ph_tile_defs,
+ .tile_def_count = (int)LEN(ph_tile_defs) };
+ PhVec2 player_start = { PH_TILE_CENTER(LEFT_COL), PH_TILE_CENTER(ROW) };
+ PhVec2 enemy_start = { PH_TILE_CENTER(MAP_W + 1), PH_TILE_CENTER(MAP_H + 1) };
+ float crossing_seconds = (float)(area.width * PH_TILE_SIZE) /
+ (float)PH_WORLD_MOVE_PIXELS_PER_SECOND;
+ PhWorld world;
+ PhBattle battle;
+ const PhEntity *player;
+ PhStats stats;
+ int order[PH_BATTLE_ORDER_COUNT];
+ int player_index;
+ int enemy_index;
+
+ 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),
+ (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,
+ .strength = 4, .magic = 3, .accuracy = 100, .agility = 1 },
+ .move_speed = 1, .kind = PH_ENTITY_PLAYER }) < 0 ||
+ ph_world_add_entity_def(&world, (PhEntityDef){
+ .id = 4, .name = "Target", .stats = { .max_hp = 8,
+ .strength = 2, .defense = 1, .accuracy = 100 },
+ .xp_reward = 7, .loot_item_id = 2, .loot_amount = 1,
+ .blocks_movement = 1, .kind = PH_ENTITY_MONSTER }) < 0 ||
+ ph_world_add_item_def(&world, (PhItemDef){ .id = 1,
+ .bonuses = { .max_hp = 2, .strength = 2 },
+ .equip_slot = PH_EQUIP_WEAPON }) < 0 ||
+ ph_world_add_item_def(&world, (PhItemDef){ .id = 2,
+ .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;
+ 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);
+ if (ph_world_equip_inventory_item(&world, player_index, 1) < 0) return 1;
+ world.entities[player_index].hp = 1;
+ if (ph_world_use_inventory_item(&world, player_index, 2) < 0 ||
+ ph_world_drop_inventory_item(&world, player_index, 2) < 0) return 1;
+
+ ph_world_tick(&world, (PhInput){ .move_x = 1 }, crossing_seconds);
+ ph_world_tick(&world, (PhInput){ .move_x = 1, .move_y = 1 }, crossing_seconds);
+ player = ph_world_player(&world);
+ stats = ph_world_entity_stats(&world, player);
+ if (!player || (int)(player->pos.x / PH_TILE_SIZE) != RIGHT_COL ||
+ (int)(player->pos.y / PH_TILE_SIZE) != ROW ||
+ world.camera.pos.x != 0.0f || world.camera.pos.y != 0.0f ||
+ ph_world_item_amount(&world, 1) != 0 ||
+ ph_world_item_amount(&world, 2) != 0 ||
+ ph_world_item_amount(&world, 3) != 1 || player->hp != 4 ||
+ stats.max_hp != 7 || stats.strength != 6 ||
+ player->equipment[PH_EQUIP_WEAPON] != 1 ||
+ !world.ground_items[0].active || world.ground_items[0].item_id != 2)
+ return 1;
+ world.entities[enemy_index].pos = (PhVec2){
+ player->pos.x - PH_ENTITY_BLOCK_RADIUS * 0.5f, player->pos.y };
+ ph_world_tick(&world, (PhInput){ .move_x = -1 }, crossing_seconds);
+ ph_battle_begin(&battle, &world, enemy_index);
+ if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) !=
+ PH_BATTLE_READY) return 1;
+ battle.command = PH_BATTLE_FIGHT;
+ ph_battle_order(&battle, &world, order);
+ if (order[0] != player_index || order[1] != enemy_index) return 1;
+ battle.command = PH_BATTLE_MAGICK;
+ ph_battle_select(&battle, &world);
+ if (battle.phase != PH_BATTLE_MAGIC || world.entities[player_index].mp != 5 ||
+ world.encounter_index != enemy_index ||
+ ph_world_apply_accuracy_penalty(&world, enemy_index, 20, 3) < 0)
+ return 1;
+ ph_world_advance_effects(&world, enemy_index);
+ ph_world_advance_effects(&world, enemy_index);
+ ph_world_advance_effects(&world, enemy_index);
+ if (world.entities[enemy_index].accuracy_penalty != 0 ||
+ world.entities[enemy_index].accuracy_turns != 0 ||
+ ph_world_physical_attack(&world, player_index, enemy_index) <= 0 ||
+ ph_world_magic_attack(&world, player_index, enemy_index, 2, 4) <= 0 ||
+ world.entities[enemy_index].hp != 0 ||
+ ph_world_finish_encounter(&world, enemy_index) < 0 ||
+ player->xp != 7 || player->mp != 3 ||
+ ph_world_item_amount(&world, 2) != 1 || world.encounter_index != -1)
+ return 1;
+ printf("smoke hp=%d equipment=%d xp=%d\n", player->hp,
+ player->equipment[PH_EQUIP_WEAPON], player->xp);
+ return 0;
+}
+
+int
+main(void)
+{
+ int open = -1;
+ int blocked = -1;
+ size_t tile;
+
+ for (tile = 0; tile < LEN(ph_tile_defs); ++tile) {
+ if (ph_tile_defs[tile].blocks_movement) blocked = (int)tile;
+ else open = (int)tile;
+ }
+ if (open < 0 || blocked < 0) {
+ fprintf(stderr, "smoke test failed: tile configuration\n");
+ return 1;
+ }
+ if (ph_movement_test(open, blocked)) {
+ fprintf(stderr, "smoke test failed: world movement\n");
+ return 1;
+ }
+ if (ph_logic_test(open, blocked)) {
+ fprintf(stderr, "smoke test failed: world or battle logic\n");
+ return 1;
+ }
+ return 0;
+}