phantasia

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

commit 30803d85bd089d287bb0aa04aaaacbb42f814c65
parent 22c1f0882a2850f569b6c94299736cf2f99162ef
Author: beep <beep@wimdupont.com>
Date:   Wed, 29 Jul 2026 13:50:43 +0000

Add autonomous animated world actors

Diffstat:
MMakefile | 15+++++++++++----
MREADME.adoc | 14+++++++++++++-
Mconfig.def.h | 6+++++-
Mphantasia.6 | 6++----
Msrc/engine/world.c | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/engine/world.h | 22+++++++++++++++++++---
Msrc/game/content.c | 30++++++++++++++++++++++--------
Msrc/game/content.h | 1+
Msrc/game/main.c | 165+++++++------------------------------------------------------------------------
Asrc/tests/smoke.c | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}