phantasia

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

commit 45aaceae9fef8bb703738666cd40c320363d17d0
parent 9806463a906b8aea502c4c79932fbedc82cc5f4d
Author: beep <beep@wimdupont.com>
Date:   Wed, 29 Jul 2026 14:40:00 +0000

Add grid-based movement and synced animations

Use smooth tile-step movement for player and world actors, drive walk frames from step progress, correct the hero's directional cycles, and wrap shop dialogue within its panel.

Diffstat:
Mconfig.def.h | 4++--
Msrc/engine/world.c | 77+++++++++++++++++++++++++++++++----------------------------------------------
Msrc/engine/world.h | 5++---
Msrc/game/content.c | 12+++++-------
Msrc/game/main.c | 57+++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/tests/smoke.c | 20+++++++++++++-------
6 files changed, 100 insertions(+), 75 deletions(-)

diff --git a/config.def.h b/config.def.h @@ -1,7 +1,7 @@ #ifndef CONFIG_H #define CONFIG_H -#define PH_CONFIG_VERSION 7 +#define PH_CONFIG_VERSION 8 #define PH_VIEW_W 320 #define PH_VIEW_H 240 @@ -14,7 +14,7 @@ #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_STEP_MILLISECONDS 250 #define PH_WORLD_ACTION_MILLISECONDS 500 #define PH_MOB_MEMORY_TURNS 4 #define PH_MOB_WANDER_PERCENT 60 diff --git a/src/engine/world.c b/src/engine/world.c @@ -12,7 +12,7 @@ #error "combat formula divisors must be positive" #endif -#if PH_WORLD_MOVE_PIXELS_PER_SECOND <= 0 || PH_WORLD_ACTION_MILLISECONDS <= 0 || \ +#if PH_WORLD_STEP_MILLISECONDS <= 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" @@ -308,11 +308,11 @@ ph_entity_begin_step(PhWorld *world, int entity_index, int dx, int dy, float radius = (float)(entity->territory_radius * PH_TILE_SIZE); if ((!dx && !dy) || entity->moving) return 0; + entity->facing_x = dx; + entity->facing_y = dy; 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; @@ -373,6 +373,7 @@ ph_world_actor_action(PhWorld *world, int entity_index) static void ph_world_advance_movers(PhWorld *world, float dt) { + float step_seconds = (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f; int i; for (i = 0; i < world->entity_count; ++i) { @@ -383,22 +384,20 @@ ph_world_advance_movers(PhWorld *world, float dt) float distance; float travel; - if (i == world->player_index || !entity->active || !entity->moving || + if (!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; 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; + travel = (float)(PH_TILE_SIZE * def->move_speed) * dt / step_seconds; 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; } } } @@ -453,7 +452,6 @@ ph_world_enter_area(PhWorld *world, PhArea area, int area_id, PhVec2 player_pos) 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); } @@ -852,31 +850,11 @@ 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) { return; @@ -887,8 +865,6 @@ 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; if (world->notice_seconds <= 0.0f) { @@ -899,22 +875,10 @@ 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) { - 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; - - 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 (!player->moving && def->move_speed > 0) + ph_entity_begin_step(world, world->player_index, + input.move_x, input.move_y, 0); + ph_world_advance_movers(world, dt); if (input.interact) { ph_try_pickup(world, player); @@ -925,6 +889,27 @@ ph_world_tick(PhWorld *world, PhInput input, float dt) ph_camera_follow_player(world); } +int +ph_entity_animation_frame(const PhEntityDef *def, const PhEntity *entity) +{ + float dx; + float dy; + float remaining; + float progress; + int frames; + int frame; + + if (!def || !entity || !entity->moving) return 0; + frames = def->animation_frames > 0 && + def->animation_frames <= PH_ANIMATION_FRAMES ? def->animation_frames : 1; + dx = entity->move_target.x - entity->pos.x; + dy = entity->move_target.y - entity->pos.y; + remaining = sqrtf(dx * dx + dy * dy); + progress = 1.0f - ph_clampf(remaining / (float)PH_TILE_SIZE, 0.0f, 1.0f); + frame = (int)(progress * (float)frames); + return frame < frames ? frame : frames - 1; +} + const PhEntity * ph_world_player(const PhWorld *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 7 +#define PH_CONFIG_API_VERSION 8 enum { PH_MAP_MAGIC = 0x50484d50, @@ -88,7 +88,6 @@ typedef struct { int asset_id; int sprite_tiles[PH_SPRITE_DIRECTIONS][PH_ANIMATION_FRAMES][2]; int animation_frames; - float animation_fps; int blocks_movement; PhEntityKind kind; } PhEntityDef; @@ -127,7 +126,6 @@ typedef struct { int chase_turns; int moving; float move_credit; - float animation_time; int equipment[PH_EQUIP_COUNT]; int active; } PhEntity; @@ -217,6 +215,7 @@ const PhEntityDef *ph_world_entity_def(const PhWorld *world, int type_id); const PhItemDef *ph_world_item_def(const PhWorld *world, int item_id); int ph_world_item_amount(const PhWorld *world, int item_id); PhStats ph_world_entity_stats(const PhWorld *world, const PhEntity *entity); +int ph_entity_animation_frame(const PhEntityDef *def, const PhEntity *entity); const PhTileDef *ph_area_tile_def(const PhArea *area, int tx, int ty); int ph_area_tile_blocked(const PhArea *area, int tx, int ty); diff --git a/src/game/content.c b/src/game/content.c @@ -34,16 +34,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 = 2, + .move_speed = 1, .starting_gold = 40, .asset_id = PH_ASSET_HERO, .sprite_tiles = { - [PH_SPRITE_DOWN] = { { 4, 0 }, { 6, 0 } }, - [PH_SPRITE_UP] = { { 0, 0 }, { 2, 0 } }, - [PH_SPRITE_SIDE] = { { 0, 1 }, { 2, 1 } }, + [PH_SPRITE_DOWN] = { { 4, 0 }, { 5, 0 }, { 6, 0 }, { 7, 0 } }, + [PH_SPRITE_UP] = { { 0, 0 }, { 1, 0 }, { 2, 0 }, { 3, 0 } }, + [PH_SPRITE_SIDE] = { { 0, 1 }, { 1, 1 }, { 2, 1 }, { 3, 1 } }, }, - .animation_frames = 2, - .animation_fps = 8.0f, + .animation_frames = 4, .blocks_movement = 0, .kind = PH_ENTITY_PLAYER, }, @@ -66,7 +65,6 @@ const PhEntityDef ph_entity_defs[PH_ENTITY_DEF_COUNT] = { [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, }, diff --git a/src/game/main.c b/src/game/main.c @@ -104,6 +104,37 @@ ph_ui_center_text(const char *text) { return ((float)PH_VIEW_W - (float)strlen(text) * PH_UI_FONT_W) * 0.5f; } + +static float +ph_draw_wrapped_text(SDL_Renderer *renderer, float x, float y, + float width, float line_height, const char *text) +{ + char line[128]; + size_t columns = width > PH_UI_FONT_W ? (size_t)(width / PH_UI_FONT_W) : 1; + + while (text && *text) { + size_t remaining; + size_t take; + size_t i; + + while (*text == ' ') ++text; + remaining = strlen(text); + take = remaining < columns ? remaining : columns; + if (take < remaining) + for (i = take; i > 0; --i) + if (text[i] == ' ') { + take = i; + break; + } + if (take >= sizeof(line)) take = sizeof(line) - 1; + memcpy(line, text, take); + line[take] = '\0'; + SDL_RenderDebugText(renderer, x, y, line); + text += take; + y += line_height; + } + return y; +} #endif #if PH_USE_SDL @@ -177,10 +208,7 @@ 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; + int frame = ph_entity_animation_frame(def, entity); *sprite_tile_x = def->sprite_tiles[direction][frame][0]; *sprite_tile_y = def->sprite_tiles[direction][frame][1]; @@ -825,9 +853,15 @@ ph_draw_shop(SDL_Renderer *renderer, const PhWorld *world, const PhShopUi *ui) } 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"); + float y = 48.0f; + float width = ph_ui_right(24.0f) - 24.0f; + + for (i = 0; i < PH_SHOP_TALK_LINES; ++i) { + y = ph_draw_wrapped_text(renderer, 24.0f, y, width, 12.0f, + shop->talk[i]); + y += 6.0f; + } + SDL_RenderDebugText(renderer, 24.0f, y + 8.0f, "ENTER / ESC BACK"); } if (ui->status[0]) SDL_RenderDebugText(renderer, 24.0f, ph_ui_bottom(36.0f), ui->status); @@ -1169,9 +1203,11 @@ ph_run_render_smoke_test(void) return 1; } - ph_world_tick(&world, (PhInput){ .move_x = 1 }, 0.1f); + ph_world_tick(&world, (PhInput){ .move_x = 1 }, + (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f); ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); - ph_world_tick(&world, (PhInput){ .move_y = 1 }, 0.18f); + ph_world_tick(&world, (PhInput){ .move_y = 1 }, + (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f); ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f); if (ph_world_item_amount(&world, ph_item_drops[0].item_id) != 1 || ph_world_item_amount(&world, ph_item_drops[1].item_id) != 1) { @@ -1306,7 +1342,8 @@ ph_run_render_smoke_test(void) result = 1; } ph_shop_open(&shop_ui, PH_SHOP_STONEHOLLOW); - context.menu = PH_MENU_NONE; + shop_ui.mode = PH_SHOP_TALK; + context.menu = PH_MENU_SHOP; ph_render_frame(renderer, &assets, &context, (PhInput){ 0 }, 0.0f); if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) { diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -47,7 +47,7 @@ ph_movement_test(int open, int blocked) (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 || + .animation_frames = 4, .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, @@ -61,11 +61,17 @@ ph_movement_test(int open, int blocked) 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; + if (mob->pos.x >= mob_start.x) 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; + world.entities[player_index].move_target.x != + PH_TILE_CENTER(PLAYER_COL + 1)) return 1; + ph_world_tick(&world, (PhInput){ 0 }, PH_MAX_FRAME_TIME); + if (ph_entity_animation_frame(&world.entity_defs[0], + &world.entities[player_index]) <= 0) return 1; + world.entities[player_index].pos = + world.entities[player_index].move_target = player_start; + world.entities[player_index].moving = 0; mob->pos = mob->move_target = (PhVec2){ PH_TILE_CENTER(PLAYER_COL + 1), player_start.y }; mob->moving = 0; @@ -114,8 +120,7 @@ ph_logic_test(int open, int blocked) .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; + float crossing_seconds = (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f; PhWorld world; PhBattle battle; const PhEntity *player; @@ -163,6 +168,7 @@ ph_logic_test(int open, int blocked) 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 || + player->facing_x != 0 || player->facing_y != 1 || 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 || @@ -172,7 +178,7 @@ ph_logic_test(int open, int blocked) !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 }; + player->pos.x - PH_TILE_SIZE, player->pos.y }; ph_world_tick(&world, (PhInput){ .move_x = -1 }, crossing_seconds); ph_battle_begin(&battle, &world, enemy_index); if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) !=