phantasia

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

commit 0ad328617881fd26dd8bafb5d661ac05fd94ed3c
parent d1044343c4931528574cacee81ecd8fc4c88aaac
Author: beep <beep@wimdupont.com>
Date:   Thu, 30 Jul 2026 10:37:29 +0000

Improve combat targeting and world landmarks

Diffstat:
MMakefile | 4++--
Mdata/maps/ashen-meadow.txt | 8++++----
Msrc/engine/battle.c | 28+++++++---------------------
Msrc/engine/render.c | 26++++++++++++++++++++++++++
Msrc/engine/world.h | 3+++
Msrc/game/content.c | 46+++++++++++++++++++++++++++++-----------------
Msrc/game/content.h | 2+-
Msrc/tests/render.c | 40++++++++++++++++++++++++++++++++++++----
Msrc/tests/smoke.c | 8+++++++-
9 files changed, 115 insertions(+), 50 deletions(-)

diff --git a/Makefile b/Makefile @@ -86,9 +86,9 @@ $(MAPDIR)/%.phmap: data/maps/%.txt $(MAPC) @mkdir -p $(dir $@) $(MAPC) $< $@ -$(TILESET_BMP): $(TILESET_SRC) +$(TILESET_BMP): $(TILESET_SRC) Makefile @mkdir -p $(dir $@) - magick $< -alpha off BMP3:$@ + magick $< -background black -alpha remove -alpha off BMP3:$@ $(HERO_BMP): $(HERO_SRC) @mkdir -p $(dir $@) diff --git a/data/maps/ashen-meadow.txt b/data/maps/ashen-meadow.txt @@ -1,10 +1,10 @@ name: Ashen Meadow ################################ -#:..;...:.....;..:.....;.......# -#..:....;..............:....e>.# -#............;......:..........# -#....@1..........:.......;.....# +#:..;...:.....;..:.....;..VVVV.# +#..:....;..............:..VVVV.# +#............;......:.....VV>V.# +#....@1..........:.......;..e..# #.....2.....:........###.......# #..;.......a.........###..:....# #........:...........###.......# diff --git a/src/engine/battle.c b/src/engine/battle.c @@ -267,32 +267,17 @@ ph_battle_cast(PhBattle *battle, PhWorld *world) snprintf(battle->message, sizeof(battle->message), "Choose a target."); } -static PhSpellTarget -ph_battle_target_kind(const PhBattle *battle) -{ - const PhSpellDef *spell; - - if (battle->choice.command == PH_BATTLE_FIGHT) - return PH_SPELL_TARGET_OPPONENT; - if (battle->choice.command == PH_BATTLE_ITEM) - return PH_SPELL_TARGET_ALLY; - spell = ph_battle_spell(battle); - return spell ? spell->target : PH_SPELL_TARGET_NONE; -} - static int ph_battle_target_valid(const PhBattle *battle, const PhWorld *world, int index) { const PhEntityDef *def; - PhSpellTarget target = ph_battle_target_kind(battle); if (index < 0 || index >= world->entity_count || !world->entities[index].active || world->entities[index].vitals.hp <= 0) return 0; - if (target == PH_SPELL_TARGET_OPPONENT) return index == battle->enemy_index; + if (index == battle->enemy_index) return 1; def = ph_world_entity_def(world, world->entities[index].type_id); - return target == PH_SPELL_TARGET_ALLY && def && - def->kind == PH_ENTITY_PLAYER && + return def && def->kind == PH_ENTITY_PLAYER && world->entities[index].area_id == world->area_id; } @@ -381,7 +366,8 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) int damage; if (battle->phase == PH_BATTLE_PLAYER_ACTION) { - def = ph_world_entity_def(world, world->entities[battle->enemy_index].type_id); + def = ph_world_entity_def(world, + world->entities[battle->choice.target_index].type_id); if (battle->choice.command == PH_BATTLE_FIGHT) { damage = ph_world_physical_attack(world, world->player_index, battle->choice.target_index); @@ -399,14 +385,14 @@ ph_battle_resolve(PhBattle *battle, PhWorld *world) damage = ph_spell_heal(world, world->player_index, battle->choice.target_index, spell); snprintf(battle->message, sizeof(battle->message), - damage >= 0 ? "%s restores %d HP." : "%s has no effect.", - spell->name, damage); + damage >= 0 ? "%s recovers %d HP." : "%s is unaffected.", + def && def->name ? def->name : "Target", damage); return; } else return; } else { const PhItemDef *item = ph_world_item_def(world, battle->choice.item_id); - damage = ph_world_use_inventory_item(world, world->player_index, + damage = ph_world_use_inventory_item(world, battle->choice.target_index, battle->choice.item_id) < 0 ? -1 : 0; snprintf(battle->message, sizeof(battle->message), "Used %s.", item && item->name ? item->name : "item"); diff --git a/src/engine/render.c b/src/engine/render.c @@ -156,6 +156,32 @@ ph_draw_area(SDL_Renderer *renderer, const PhRenderAssets *assets, const PhWorld sprite_tile_x += joins_west == joins_east ? 1 : joins_west ? 2 : 0; sprite_tile_y += joins_north == joins_south ? 1 : joins_north ? 2 : 0; + } else if (def->autotile == PH_TILE_AUTOTILE_FIXED && + def->autotile_width > 0 && def->autotile_height > 0) { + const PhTileDef *neighbor; + int offset_x = 0; + int offset_y = 0; + + while (offset_x < def->autotile_width - 1 && + (neighbor = ph_area_tile_def(&world->area, + tx - offset_x - 1, ty)) && + neighbor->autotile == def->autotile && + neighbor->asset_id == def->asset_id && + neighbor->sprite_tile_x == def->sprite_tile_x && + neighbor->sprite_tile_y == def->sprite_tile_y && + neighbor->autotile_width == def->autotile_width && + neighbor->autotile_height == def->autotile_height) ++offset_x; + while (offset_y < def->autotile_height - 1 && + (neighbor = ph_area_tile_def(&world->area, + tx, ty - offset_y - 1)) && + neighbor->autotile == def->autotile && + neighbor->asset_id == def->asset_id && + neighbor->sprite_tile_x == def->sprite_tile_x && + neighbor->sprite_tile_y == def->sprite_tile_y && + neighbor->autotile_width == def->autotile_width && + neighbor->autotile_height == def->autotile_height) ++offset_y; + sprite_tile_x += offset_x; + sprite_tile_y += offset_y; } src = (SDL_FRect){ .x = (float)(sprite_tile_x * PH_TILE_SIZE), diff --git a/src/engine/world.h b/src/engine/world.h @@ -113,12 +113,15 @@ typedef struct { int background_sprite_tile_x; int background_sprite_tile_y; int autotile; + int autotile_width; + int autotile_height; const char *interaction_notice; } PhTileDef; enum { PH_TILE_AUTOTILE_NONE, PH_TILE_AUTOTILE_3X3, + PH_TILE_AUTOTILE_FIXED, }; typedef struct { diff --git a/src/game/content.c b/src/game/content.c @@ -15,13 +15,13 @@ const PhAreaDef ph_area_defs[PH_AREA_COUNT] = { }; const PhTileDef ph_tile_defs[PH_TILE_DEF_COUNT] = { - { '.', 0, PH_ASSET_TILESET, 1, 1, 0, 0, 0, 0, 0 }, - { ':', 0, PH_ASSET_TILESET, 1, 0, 0, 0, 0, 0, 0 }, - { ';', 0, PH_ASSET_TILESET, 2, 0, 0, 0, 0, 0, 0 }, - { ',', 0, PH_ASSET_TILESET, 6, 2, 0, 0, 0, 0, 0 }, - { 'T', 0, PH_ASSET_TILESET, 6, 2, 0, 0, 0, 0, 0 }, + { '.', 0, PH_ASSET_TILESET, 1, 1, 0, 0, 0, 0, 0, 0, 0 }, + { ':', 0, PH_ASSET_TILESET, 1, 0, 0, 0, 0, 0, 0, 0, 0 }, + { ';', 0, PH_ASSET_TILESET, 2, 0, 0, 0, 0, 0, 0, 0, 0 }, + { ',', 0, PH_ASSET_TILESET, 6, 2, 0, 0, 0, 0, 0, 0, 0 }, + { 'T', 0, PH_ASSET_TILESET, 6, 2, 0, 0, 0, 0, 0, 0, 0 }, { '#', 1, PH_ASSET_TILESET, 9, 4, 0, 0, 0, - PH_TILE_AUTOTILE_3X3, 0 }, + PH_TILE_AUTOTILE_3X3, 0, 0, 0 }, { .symbol = 'J', .blocks_movement = 1, .asset_id = PH_ASSET_TILESET, .sprite_tile_x = 0, .sprite_tile_y = 16, .background_asset_id = PH_ASSET_TILESET, @@ -75,16 +75,28 @@ const PhTileDef ph_tile_defs[PH_TILE_DEF_COUNT] = { .sprite_tile_x = 1, .sprite_tile_y = 16, .background_asset_id = PH_ASSET_TILESET, .background_sprite_tile_x = 1, .background_sprite_tile_y = 1 }, - { '[', 0, PH_ASSET_TILESET, 9, 1, 0, 0, 0, 0, 0 }, - { '^', 0, PH_ASSET_TILESET, 10, 1, 0, 0, 0, 0, 0 }, - { ']', 0, PH_ASSET_TILESET, 11, 1, 0, 0, 0, 0, 0 }, - { '{', 0, PH_ASSET_TILESET, 9, 2, 0, 0, 0, 0, 0 }, - { '=', 0, PH_ASSET_TILESET, 10, 2, 0, 0, 0, 0, 0 }, - { '}', 0, PH_ASSET_TILESET, 11, 2, 0, 0, 0, 0, 0 }, - { '(', 1, PH_ASSET_TILESET, 9, 3, 0, 0, 0, 0, 0 }, - { '_', 1, PH_ASSET_TILESET, 10, 3, 0, 0, 0, 0, 0 }, - { ')', 1, PH_ASSET_TILESET, 11, 3, 0, 0, 0, 0, 0 }, - { '+', 0, PH_ASSET_TILESET, 5, 15, 0, 0, 0, 0, 0 }, + { '[', 0, PH_ASSET_TILESET, 9, 1, 0, 0, 0, 0, 0, 0, 0 }, + { '^', 0, PH_ASSET_TILESET, 10, 1, 0, 0, 0, 0, 0, 0, 0 }, + { ']', 0, PH_ASSET_TILESET, 11, 1, 0, 0, 0, 0, 0, 0, 0 }, + { '{', 0, PH_ASSET_TILESET, 9, 2, 0, 0, 0, 0, 0, 0, 0 }, + { '=', 0, PH_ASSET_TILESET, 10, 2, 0, 0, 0, 0, 0, 0, 0 }, + { '}', 0, PH_ASSET_TILESET, 11, 2, 0, 0, 0, 0, 0, 0, 0 }, + { '(', 1, PH_ASSET_TILESET, 9, 3, 0, 0, 0, 0, 0, 0, 0 }, + { '_', 1, PH_ASSET_TILESET, 10, 3, 0, 0, 0, 0, 0, 0, 0 }, + { ')', 1, PH_ASSET_TILESET, 11, 3, 0, 0, 0, 0, 0, 0, 0 }, + { '+', 0, PH_ASSET_TILESET, 5, 15, 0, 0, 0, 0, 0, 0, 0 }, + { .symbol = 'V', .blocks_movement = 1, .asset_id = PH_ASSET_TILESET, + .sprite_tile_x = 0, .sprite_tile_y = 13, + .background_asset_id = PH_ASSET_TILESET, + .background_sprite_tile_x = 1, .background_sprite_tile_y = 1, + .autotile = PH_TILE_AUTOTILE_FIXED, + .autotile_width = 4, .autotile_height = 3 }, + { .symbol = 'v', .asset_id = PH_ASSET_TILESET, + .sprite_tile_x = 0, .sprite_tile_y = 13, + .background_asset_id = PH_ASSET_TILESET, + .background_sprite_tile_x = 1, .background_sprite_tile_y = 1, + .autotile = PH_TILE_AUTOTILE_FIXED, + .autotile_width = 4, .autotile_height = 3 }, }; const PhSpellDef ph_spell_defs[PH_SPELL_DEF_COUNT] = { @@ -272,7 +284,7 @@ const PhMarkerDef ph_marker_defs[PH_MARKER_DEF_COUNT] = { PH_ITEM_RUSTED_SPEAR, .amount = 1 }, { PH_AREA_MEADOW, '2', '.', PH_MARKER_ITEM, PH_ITEM_HEALING_POTION, .amount = 1 }, - { PH_AREA_MEADOW, '>', 'T', PH_MARKER_PORTAL, + { PH_AREA_MEADOW, '>', 'v', PH_MARKER_PORTAL, .destination_area_id = PH_AREA_STONEHOLLOW, .destination_symbol = 'e' }, { PH_AREA_MEADOW, 'e', '.', PH_MARKER_ARRIVAL, .content_id = 0 }, { PH_AREA_STONEHOLLOW, '>', 'j', PH_MARKER_PORTAL, diff --git a/src/game/content.h b/src/game/content.h @@ -92,7 +92,7 @@ enum { }; enum { - PH_TILE_DEF_COUNT = 27, + PH_TILE_DEF_COUNT = 29, PH_MARKER_DEF_COUNT = 18, }; diff --git a/src/tests/render.c b/src/tests/render.c @@ -8,6 +8,25 @@ #include "config.h" #if PH_USE_SDL +static int +ph_render_enter_portal(PhWorld *world, int destination_area_id) +{ + int i; + + for (i = 0; i < world->area.marker_count; ++i) { + const PhAreaMarker *marker = &world->area.markers[i]; + const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas, + world->area_id, marker->symbol); + + if (!def || def->kind != PH_MARKER_PORTAL || + def->destination_area_id != destination_area_id) continue; + world->entities[world->player_index].pos = (PhVec2){ + PH_TILE_CENTER(marker->tile_x), PH_TILE_CENTER(marker->tile_y) }; + return ph_world_update_portal(world, ph_game_world.areas); + } + return -1; +} + int main(void) { @@ -219,19 +238,21 @@ main(void) ph_present_frame(renderer, &assets, &context); world.entities[enemy_index].vitals.hp = ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp; - world.entities[world.player_index].vitals.hp -= 18; + world.entities[enemy_index].vitals.hp = 1; battle.choice.command = PH_BATTLE_MAGICK; ph_battle_select(&battle, &world); battle.choice.spell = 1; ph_battle_cast(&battle, &world); if (battle.phase != PH_BATTLE_TARGET || battle.choice.target_index != world.player_index) result = 1; + if (ph_battle_target_step(&battle, &world, 1) != enemy_index) result = 1; + ph_present_frame(renderer, &assets, &context); ph_battle_confirm_target(&battle, &world); ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); - if (ph_world_player(&world)->vitals.hp != - ph_world_entity_stats(&world, ph_world_player(&world)).max_hp || + if (world.entities[enemy_index].vitals.hp != + ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp || ph_world_player(&world)->vitals.mp != 40) { - fprintf(stderr, "render smoke test failed: combat Heal\n"); + fprintf(stderr, "render smoke test failed: healing enemy target\n"); result = 1; } world.entities[world.player_index].vitals.mp = @@ -339,6 +360,17 @@ main(void) shop_ui.mode = PH_SHOP_TALK; context.menu = PH_MENU_SHOP; ph_present_frame(renderer, &assets, &context); + if (ph_render_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1) { + fprintf(stderr, "render smoke test failed: return to town\n"); + result = 1; + } + context.menu = PH_MENU_NONE; + ph_present_frame(renderer, &assets, &context); + if (ph_render_enter_portal(&world, PH_AREA_MEADOW) != 1) { + fprintf(stderr, "render smoke test failed: return to meadow\n"); + result = 1; + } + ph_present_frame(renderer, &assets, &context); if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) { fprintf(stderr, "SDL_SaveBMP failed: %s\n", SDL_GetError()); diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -280,7 +280,9 @@ ph_logic_test(int open, int blocked) world.entities[player_index].vitals.mp = 20; ph_battle_cast(&battle, &world); if (battle.phase != PH_BATTLE_TARGET || - battle.choice.target_index != enemy_index) return 1; + battle.choice.target_index != enemy_index || + ph_battle_target_step(&battle, &world, 1) != player_index || + ph_battle_target_step(&battle, &world, 1) != enemy_index) return 1; ph_battle_cancel(&battle); battle.choice.spell = 1; ph_battle_cast(&battle, &world); @@ -288,6 +290,7 @@ ph_logic_test(int open, int blocked) if (battle.phase != PH_BATTLE_TARGET || battle.choice.target_index != player_index || ally_index < 0 || + ph_battle_target_step(&battle, &world, 1) != enemy_index || ph_battle_target_step(&battle, &world, 1) != ally_index || ph_battle_target_step(&battle, &world, 1) != player_index) return 1; ph_battle_cancel(&battle); @@ -364,6 +367,9 @@ ph_area_shop_test(void) player->vitals.mp != 40) goto fail; player->vitals.hp = ph_world_entity_stats(&world, player).max_hp; player->vitals.mp = ph_world_entity_stats(&world, player).max_mp; + if (!ph_area_tile_blocked(&world.area, 26, 1) || + !ph_area_tile_blocked(&world.area, 27, 3) || + ph_area_tile_blocked(&world.area, 28, 3)) goto fail; if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 || world.area_id != PH_AREA_STONEHOLLOW || world.initialized_area_count != 2) goto fail;