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:
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) !=