phantasia

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

render.c (18424B)


      1 #include "engine/battle.h"
      2 #include "engine/area.h"
      3 #include "engine/audio.h"
      4 #include "game/content.h"
      5 #include "game/presentation.h"
      6 
      7 #include <stdio.h>
      8 
      9 #include "config.h"
     10 
     11 #if PH_USE_SDL
     12 static int
     13 ph_render_enter_portal(PhWorld *world, int destination_area_id)
     14 {
     15 	int i;
     16 
     17 	for (i = 0; i < world->area.marker_count; ++i) {
     18 		const PhAreaMarker *marker = &world->area.markers[i];
     19 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
     20 			world->area_id, marker->symbol);
     21 
     22 		if (!def || def->kind != PH_MARKER_PORTAL ||
     23 				def->destination_area_id != destination_area_id) continue;
     24 		world->entities[world->player_index].pos = (PhVec2){
     25 			PH_TILE_CENTER(marker->tile_x), PH_TILE_CENTER(marker->tile_y) };
     26 		return ph_world_update_portal(world, ph_game_world.areas);
     27 	}
     28 	return -1;
     29 }
     30 
     31 int
     32 main(void)
     33 {
     34 	SDL_Surface *surface;
     35 	SDL_Renderer *renderer;
     36 	PhRenderAssets assets;
     37 	PhAudio audio = { 0 };
     38 	PhWorld world;
     39 	PhInventoryUi inventory_ui;
     40 	PhSpellbookUi spellbook_ui;
     41 	PhShopUi shop_ui = { 0 };
     42 	PhOptionsUi options_ui = { .music_enabled = 1, .effects_enabled = 1 };
     43 	PhStartUi start_ui = { .selected = 1 };
     44 	PhGameMode game = PH_GAME_WORLD;
     45 	PhBattle battle = { 0 };
     46 	PhSpellBook spells = ph_player_spellbook;
     47 	PhGameContext context = {
     48 		.world = &world,
     49 		.inventory = &inventory_ui,
     50 		.spellbook = &spellbook_ui,
     51 		.shop = &shop_ui,
     52 		.options = &options_ui,
     53 		.start = &start_ui,
     54 		.mode = &game,
     55 		.battle = &battle,
     56 		.spells = &spells,
     57 	};
     58 	int i;
     59 	int enemy_index = -1;
     60 	int result = 0;
     61 	int order[PH_BATTLE_ORDER_COUNT];
     62 	PhBattleResult battle_result;
     63 	int first_item;
     64 	int second_item;
     65 
     66 	if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) {
     67 		fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
     68 		return 1;
     69 	}
     70 
     71 	surface = SDL_CreateSurface(PH_VIEW_W, PH_VIEW_H, SDL_PIXELFORMAT_ARGB8888);
     72 	if (!surface) {
     73 		fprintf(stderr, "SDL_CreateSurface failed: %s\n", SDL_GetError());
     74 		SDL_Quit();
     75 		return 1;
     76 	}
     77 
     78 	renderer = SDL_CreateSoftwareRenderer(surface);
     79 	if (!renderer) {
     80 		fprintf(stderr, "SDL_CreateSoftwareRenderer failed: %s\n", SDL_GetError());
     81 		SDL_DestroySurface(surface);
     82 		SDL_Quit();
     83 		return 1;
     84 	}
     85 
     86 	if (ph_world_start(&world, &ph_game_world,
     87 			(float)PH_VIEW_W, (float)PH_VIEW_H) < 0) {
     88 		SDL_DestroyRenderer(renderer);
     89 		SDL_DestroySurface(surface);
     90 		SDL_Quit();
     91 		return 1;
     92 	}
     93 	if (world.ground_item_count < 2) {
     94 		fprintf(stderr, "render smoke test failed: item setup\n");
     95 		ph_world_destroy(&world);
     96 		SDL_DestroyRenderer(renderer);
     97 		SDL_DestroySurface(surface);
     98 		SDL_Quit();
     99 		return 1;
    100 	}
    101 	first_item = world.ground_items[0].item_id;
    102 	second_item = world.ground_items[1].item_id;
    103 
    104 	if (ph_render_assets_load(&assets, renderer,
    105 			ph_asset_defs, PH_ASSET_COUNT) < 0) {
    106 		ph_world_destroy(&world);
    107 		SDL_DestroyRenderer(renderer);
    108 		SDL_DestroySurface(surface);
    109 		SDL_Quit();
    110 		return 1;
    111 	}
    112 	if (ph_start_key(&start_ui, PH_KEY_MENU_ACCEPT) != PH_START_NEW) {
    113 		fprintf(stderr, "render smoke test failed: new-game selection\n");
    114 		result = 1;
    115 	}
    116 	start_ui.can_continue = 1;
    117 	start_ui.selected = 0;
    118 	if (ph_start_key(&start_ui, PH_KEY_MENU_ACCEPT) != PH_START_CONTINUE) {
    119 		fprintf(stderr, "render smoke test failed: continue selection\n");
    120 		result = 1;
    121 	}
    122 	context.menu = PH_MENU_START;
    123 	ph_present_frame(renderer, &assets, &context);
    124 	if (ph_audio_init(&audio, ph_audio_defs, PH_AUDIO_COUNT) < 0) {
    125 		fprintf(stderr, "audio smoke test failed: %s\n", SDL_GetError());
    126 		ph_render_assets_destroy(&assets);
    127 		ph_world_destroy(&world);
    128 		SDL_DestroyRenderer(renderer);
    129 		SDL_DestroySurface(surface);
    130 		SDL_Quit();
    131 		return 1;
    132 	}
    133 	ph_audio_music(&audio, PH_MUSIC_MEADOW);
    134 	ph_audio_update(&audio);
    135 	ph_audio_effect(&audio, PH_SOUND_ATTACK, 1.0f);
    136 	ph_audio_effect(&audio, PH_SOUND_HIT, 1.0f);
    137 	ph_audio_effect(&audio, PH_SOUND_SPELL, 1.0f);
    138 	ph_audio_effect(&audio, PH_SOUND_MENU, 0.5f);
    139 	ph_audio_set_enabled(&audio, 0, 0);
    140 	if (audio.music_enabled || audio.effects_enabled ||
    141 			ph_audio_distance_gain(0.0f, 0.0f, 160.0f, 0.0f, 160.0f) != 0.0f ||
    142 			ph_audio_distance_gain(0.0f, 0.0f, 80.0f, 0.0f, 160.0f) != 0.5f) {
    143 		fprintf(stderr, "audio smoke test failed: controls or distance\n");
    144 		result = 1;
    145 	}
    146 	ph_audio_set_enabled(&audio, 1, 1);
    147 	ph_audio_music(&audio, PH_MUSIC_TOWN);
    148 	ph_audio_music(&audio, PH_MUSIC_BATTLE);
    149 
    150 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    151 	if (ph_world_item_amount(&world, first_item) != 0) {
    152 		fprintf(stderr, "render smoke test failed: adjacent item pickup\n");
    153 		result = 1;
    154 	}
    155 	ph_world_tick(&world, (PhInput){ .move_x = 1 },
    156 		(float)PH_WORLD_STEP_MILLISECONDS / 1000.0f);
    157 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    158 	ph_world_tick(&world, (PhInput){ .move_y = 1 },
    159 		(float)PH_WORLD_STEP_MILLISECONDS / 1000.0f);
    160 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    161 	if (ph_world_item_amount(&world, first_item) != 1 ||
    162 			ph_world_item_amount(&world, second_item) != 1) {
    163 		fprintf(stderr, "render smoke test failed: item pickup\n");
    164 		result = 1;
    165 	}
    166 	ph_inventory_open(&inventory_ui, &world);
    167 	ph_options_open(&options_ui);
    168 	if (ph_options_key(&options_ui, PH_KEY_MENU_DOWN_1) != PH_OPTIONS_STAY ||
    169 			options_ui.selected != 1 ||
    170 			ph_options_key(&options_ui, PH_KEY_MENU_UP_1) != PH_OPTIONS_STAY ||
    171 			options_ui.selected != 0 ||
    172 			ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT) != PH_OPTIONS_STAY ||
    173 			options_ui.mode != PH_OPTIONS_SOUND) {
    174 		fprintf(stderr, "render smoke test failed: options navigation\n");
    175 		result = 1;
    176 	}
    177 	ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT);
    178 	ph_options_key(&options_ui, PH_KEY_MENU_DOWN_1);
    179 	ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT);
    180 	if (options_ui.music_enabled || options_ui.effects_enabled) {
    181 		fprintf(stderr, "render smoke test failed: sound toggles\n");
    182 		result = 1;
    183 	}
    184 	context.menu = PH_MENU_MAIN;
    185 	ph_present_frame(renderer, &assets, &context);
    186 	ph_options_key(&options_ui, PH_KEY_MENU_BACK);
    187 	if (ph_options_key(&options_ui, PH_KEY_MENU_BACK) != PH_OPTIONS_CLOSE) {
    188 		fprintf(stderr, "render smoke test failed: options cancel\n");
    189 		result = 1;
    190 	}
    191 	ph_options_open(&options_ui);
    192 	ph_options_key(&options_ui, PH_KEY_MENU_DOWN_1);
    193 	if (ph_options_key(&options_ui, PH_KEY_MENU_ACCEPT) != PH_OPTIONS_EXIT) {
    194 		fprintf(stderr, "render smoke test failed: exit option\n");
    195 		result = 1;
    196 	}
    197 	options_ui.music_enabled = 1;
    198 	options_ui.effects_enabled = 1;
    199 	context.menu = PH_MENU_NONE;
    200 	ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT);
    201 	if (inventory_ui.option != PH_INVENTORY_EQUIP) result = 1;
    202 	ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1);
    203 	if (inventory_ui.option != PH_INVENTORY_DROP) result = 1;
    204 	inventory_ui.mode = PH_INVENTORY_BROWSE;
    205 	ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1);
    206 	ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_ACCEPT);
    207 	if (inventory_ui.option != PH_INVENTORY_USE) result = 1;
    208 	ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_DOWN_1);
    209 	if (inventory_ui.option != PH_INVENTORY_DROP) {
    210 		fprintf(stderr, "render smoke test failed: inventory actions\n");
    211 		result = 1;
    212 	}
    213 	inventory_ui.mode = PH_INVENTORY_EXAMINE;
    214 	if (ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_BACK) ||
    215 			inventory_ui.mode != PH_INVENTORY_ACTIONS ||
    216 			ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_BACK) ||
    217 			inventory_ui.mode != PH_INVENTORY_BROWSE ||
    218 			!ph_inventory_key(&inventory_ui, &world, PH_KEY_MENU_BACK)) {
    219 		fprintf(stderr, "render smoke test failed: inventory cancel\n");
    220 		result = 1;
    221 	}
    222 	ph_inventory_open(&inventory_ui, &world);
    223 	for (i = 0; i < 10; ++i) {
    224 		inventory_ui.mode = i == 8 ? PH_INVENTORY_EXAMINE :
    225 			i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE;
    226 		context.menu = i == 7 ? PH_MENU_CHARACTER :
    227 				i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE;
    228 		ph_present_frame(renderer, &assets, &context);
    229 	}
    230 	ph_spellbook_open(&spellbook_ui, &spells);
    231 	ph_spellbook_key(&spellbook_ui, &world, &spells, PH_KEY_MENU_ACCEPT);
    232 	if (spellbook_ui.option != PH_SPELLBOOK_EXAMINE_ACTION) {
    233 		fprintf(stderr, "render smoke test failed: disabled field spell\n");
    234 		result = 1;
    235 	}
    236 	spellbook_ui.mode = PH_SPELLBOOK_BROWSE;
    237 	spellbook_ui.selected = 1;
    238 	ph_spellbook_key(&spellbook_ui, &world, &spells, PH_KEY_MENU_ACCEPT);
    239 	if (spellbook_ui.option != PH_SPELLBOOK_USE) result = 1;
    240 	world.entities[world.player_index].vitals.hp -= 10;
    241 	ph_spellbook_key(&spellbook_ui, &world, &spells, PH_KEY_MENU_ACCEPT);
    242 	if (ph_world_player(&world)->vitals.hp !=
    243 			ph_world_entity_stats(&world, ph_world_player(&world)).max_hp ||
    244 			ph_world_player(&world)->vitals.mp != 40) {
    245 		fprintf(stderr, "render smoke test failed: field Heal\n");
    246 		result = 1;
    247 	}
    248 	world.entities[world.player_index].vitals.mp =
    249 		ph_world_entity_stats(&world, ph_world_player(&world)).max_mp;
    250 	context.menu = PH_MENU_SPELLBOOK;
    251 	ph_present_frame(renderer, &assets, &context);
    252 	spellbook_ui.mode = PH_SPELLBOOK_ACTIONS;
    253 	ph_present_frame(renderer, &assets, &context);
    254 	spellbook_ui.mode = PH_SPELLBOOK_EXAMINE;
    255 	ph_present_frame(renderer, &assets, &context);
    256 	ph_shop_open(&shop_ui, ph_shop_defs[0].id);
    257 	for (i = PH_SHOP_ROOT; i <= PH_SHOP_TALK; ++i) {
    258 		shop_ui.mode = (PhShopMode)i;
    259 		shop_ui.selected = i == PH_SHOP_SELL ?
    260 			ph_inventory_step(&world, -1, 1) : 0;
    261 		context.menu = PH_MENU_SHOP;
    262 		ph_present_frame(renderer, &assets, &context);
    263 	}
    264 	ph_shop_open(&shop_ui, PH_SHOP_MAGICK);
    265 	shop_ui.mode = PH_SHOP_ROOT;
    266 	ph_present_frame(renderer, &assets, &context);
    267 	ph_shop_key(&shop_ui, &world, &spells, PH_KEY_MENU_ACCEPT);
    268 	if (shop_ui.mode != PH_SHOP_BUY) result = 1;
    269 	ph_present_frame(renderer, &assets, &context);
    270 	ph_shop_key(&shop_ui, &world, &spells, PH_KEY_MENU_ACCEPT);
    271 	if (!ph_spellbook_has(&spells, PH_SPELL_EMBER) ||
    272 			ph_world_player(&world)->gold != 16) {
    273 		fprintf(stderr, "render smoke test failed: magick purchase\n");
    274 		result = 1;
    275 	}
    276 	ph_present_frame(renderer, &assets, &context);
    277 	shop_ui.mode = PH_SHOP_TALK;
    278 	ph_present_frame(renderer, &assets, &context);
    279 	for (i = 0; i < world.entity_count; ++i) {
    280 		const PhEntityDef *def = ph_world_entity_def(&world, world.entities[i].type_id);
    281 		if (def && def->kind == PH_ENTITY_MONSTER) enemy_index = i;
    282 	}
    283 	if (enemy_index < 0) {
    284 		fprintf(stderr, "render smoke test failed: enemy setup\n");
    285 		result = 1;
    286 	} else {
    287 		world.encounter_index = enemy_index;
    288 		game = PH_GAME_WORLD;
    289 		context.menu = PH_MENU_NONE;
    290 		ph_battle_begin(&battle, &world, enemy_index, spells);
    291 		game = PH_GAME_TRANSITION;
    292 		ph_present_frame(renderer, &assets, &context);
    293 		if (game != PH_GAME_TRANSITION) result = 1;
    294 		context.menu = PH_MENU_NONE;
    295 		ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS * 0.5f, 1);
    296 		ph_present_frame(renderer, &assets, &context);
    297 		game = PH_GAME_BATTLE;
    298 		ph_options_open(&options_ui);
    299 		context.menu = PH_MENU_MAIN;
    300 		ph_present_frame(renderer, &assets, &context);
    301 		context.menu = PH_MENU_NONE;
    302 		battle.phase = PH_BATTLE_ENEMY_ACTION;
    303 		battle.timer = PH_BATTLE_ACTION_SECONDS;
    304 		battle.resolved = 0;
    305 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    306 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_HIT) {
    307 			fprintf(stderr, "render smoke test failed: enemy sound event\n");
    308 			result = 1;
    309 		}
    310 		battle.timer = 0.0f;
    311 		world.entities[world.player_index].vitals.hp = 0;
    312 		context.menu = PH_MENU_NONE;
    313 		battle_result = ph_battle_update(&battle, &world, 0.0f, 0);
    314 		if (battle_result == PH_BATTLE_DEFEAT) game = PH_GAME_OVER;
    315 		ph_present_frame(renderer, &assets, &context);
    316 		if (game != PH_GAME_OVER) result = 1;
    317 		world.entities[world.player_index].vitals.hp =
    318 			ph_world_entity_stats(&world, ph_world_player(&world)).max_hp;
    319 		ph_battle_begin(&battle, &world, enemy_index, spells);
    320 		game = PH_GAME_TRANSITION;
    321 		context.menu = PH_MENU_NONE;
    322 		if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) ==
    323 				PH_BATTLE_READY) game = PH_GAME_BATTLE;
    324 		ph_present_frame(renderer, &assets, &context);
    325 		world.entities[enemy_index].vitals.hp =
    326 			ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp;
    327 		world.entities[enemy_index].vitals.hp = 1;
    328 		battle.choice.command = PH_BATTLE_MAGICK;
    329 		ph_battle_select(&battle, &world);
    330 		battle.choice.spell = 1;
    331 		ph_battle_cast(&battle, &world);
    332 		if (battle.phase != PH_BATTLE_TARGET ||
    333 				battle.choice.target_index != world.player_index) result = 1;
    334 		if (ph_battle_target_step(&battle, &world, 1) != enemy_index) result = 1;
    335 		ph_present_frame(renderer, &assets, &context);
    336 		ph_battle_confirm_target(&battle, &world);
    337 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    338 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_SPELL) {
    339 			fprintf(stderr, "render smoke test failed: healing sound event\n");
    340 			result = 1;
    341 		}
    342 		if (world.entities[enemy_index].vitals.hp !=
    343 				ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp ||
    344 				ph_world_player(&world)->vitals.mp != 40) {
    345 			fprintf(stderr, "render smoke test failed: healing enemy target\n");
    346 			result = 1;
    347 		}
    348 		world.entities[world.player_index].vitals.mp =
    349 			ph_world_entity_stats(&world, ph_world_player(&world)).max_mp;
    350 		ph_battle_begin(&battle, &world, enemy_index, spells);
    351 		ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1);
    352 		battle.choice.command = PH_BATTLE_FIGHT;
    353 		ph_battle_order(&battle, &world, order);
    354 		if (order[0] != world.player_index || order[1] != enemy_index) {
    355 			fprintf(stderr, "render smoke test failed: normal CTB order\n");
    356 			result = 1;
    357 		}
    358 		battle.choice.command = PH_BATTLE_MAGICK;
    359 		ph_battle_order(&battle, &world, order);
    360 		if (order[0] != world.player_index || order[1] != enemy_index) {
    361 			fprintf(stderr, "render smoke test failed: command CTB order\n");
    362 			result = 1;
    363 		}
    364 		ph_battle_select(&battle, &world);
    365 		if (battle.phase != PH_BATTLE_MAGIC || ph_world_player(&world)->vitals.mp != 50) {
    366 			fprintf(stderr, "render smoke test failed: Magick submenu\n");
    367 			result = 1;
    368 		}
    369 		ph_battle_order(&battle, &world, order);
    370 		if (order[0] != world.player_index || order[1] != world.player_index) {
    371 			fprintf(stderr, "render smoke test failed: spell CTB order\n");
    372 			result = 1;
    373 		}
    374 		context.menu = PH_MENU_NONE;
    375 		ph_present_frame(renderer, &assets, &context);
    376 		ph_battle_cast(&battle, &world);
    377 		if (battle.phase != PH_BATTLE_TARGET ||
    378 				battle.choice.target_index != enemy_index) {
    379 			fprintf(stderr, "render smoke test failed: offensive target default\n");
    380 			result = 1;
    381 		}
    382 		ph_present_frame(renderer, &assets, &context);
    383 		ph_battle_confirm_target(&battle, &world);
    384 		context.menu = PH_MENU_NONE;
    385 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    386 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_SPELL) {
    387 			fprintf(stderr, "render smoke test failed: spell sound event\n");
    388 			result = 1;
    389 		}
    390 		ph_present_frame(renderer, &assets, &context);
    391 		context.menu = PH_MENU_NONE;
    392 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    393 		ph_present_frame(renderer, &assets, &context);
    394 		if (game != PH_GAME_BATTLE || battle.phase != PH_BATTLE_COMMAND ||
    395 				battle.turn.current_actor != world.player_index ||
    396 				ph_world_player(&world)->vitals.mp != 35 ||
    397 				world.entities[enemy_index].accuracy.value != 20 ||
    398 				world.entities[enemy_index].accuracy.turns != 3) {
    399 			fprintf(stderr, "render smoke test failed: Flash action\n");
    400 			result = 1;
    401 		}
    402 		battle.choice.command = PH_BATTLE_FIGHT;
    403 		ph_battle_select(&battle, &world);
    404 		ph_battle_confirm_target(&battle, &world);
    405 		context.menu = PH_MENU_NONE;
    406 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    407 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_ATTACK) {
    408 			fprintf(stderr, "render smoke test failed: attack sound event\n");
    409 			result = 1;
    410 		}
    411 		ph_present_frame(renderer, &assets, &context);
    412 		context.menu = PH_MENU_NONE;
    413 		battle_result = ph_battle_update(&battle, &world,
    414 			PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    415 		if (battle_result == PH_BATTLE_VICTORY) game = PH_GAME_VICTORY;
    416 		ph_present_frame(renderer, &assets, &context);
    417 		if (game != PH_GAME_VICTORY ||
    418 				ph_world_player(&world)->xp != battle.reward.xp ||
    419 				ph_world_item_amount(&world, battle.reward.item_id) != 2) {
    420 			fprintf(stderr, "render smoke test failed: victory rewards\n");
    421 			result = 1;
    422 		}
    423 	}
    424 	game = PH_GAME_WORLD;
    425 	for (i = 0; i < world.area.marker_count; ++i) {
    426 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    427 			world.area_id, world.area.markers[i].symbol);
    428 
    429 		if (!def || def->kind != PH_MARKER_PORTAL) continue;
    430 		world.entities[world.player_index].pos = (PhVec2){
    431 			PH_TILE_CENTER(world.area.markers[i].tile_x),
    432 			PH_TILE_CENTER(world.area.markers[i].tile_y) };
    433 		break;
    434 	}
    435 	if (i == world.area.marker_count ||
    436 			ph_world_update_portal(&world, ph_game_world.areas) != 1) {
    437 		fprintf(stderr, "render smoke test failed: town portal\n");
    438 		result = 1;
    439 	}
    440 	for (i = 0; i < world.area.marker_count; ++i) {
    441 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    442 			world.area_id, world.area.markers[i].symbol);
    443 
    444 		if (!def || def->kind != PH_MARKER_PORTAL ||
    445 				def->destination_area_id != PH_AREA_MIRA_SHOP) continue;
    446 		world.entities[world.player_index].pos = (PhVec2){
    447 			PH_TILE_CENTER(world.area.markers[i].tile_x),
    448 			PH_TILE_CENTER(world.area.markers[i].tile_y) };
    449 		break;
    450 	}
    451 	if (i == world.area.marker_count ||
    452 			ph_world_update_portal(&world, ph_game_world.areas) != 1 ||
    453 			world.area_id != PH_AREA_MIRA_SHOP) {
    454 		fprintf(stderr, "render smoke test failed: shop portal\n");
    455 		result = 1;
    456 	}
    457 	ph_shop_open(&shop_ui, ph_shop_defs[0].id);
    458 	shop_ui.mode = PH_SHOP_TALK;
    459 	context.menu = PH_MENU_SHOP;
    460 	ph_present_frame(renderer, &assets, &context);
    461 	if (ph_render_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1) {
    462 		fprintf(stderr, "render smoke test failed: return to town\n");
    463 		result = 1;
    464 	}
    465 	context.menu = PH_MENU_NONE;
    466 	ph_present_frame(renderer, &assets, &context);
    467 	if (ph_render_enter_portal(&world, PH_AREA_MEADOW) != 1) {
    468 		fprintf(stderr, "render smoke test failed: return to meadow\n");
    469 		result = 1;
    470 	}
    471 	ph_present_frame(renderer, &assets, &context);
    472 
    473 	if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) {
    474 		fprintf(stderr, "SDL_SaveBMP failed: %s\n", SDL_GetError());
    475 		result = 1;
    476 	}
    477 
    478 	ph_audio_destroy(&audio);
    479 	ph_render_assets_destroy(&assets);
    480 	ph_world_destroy(&world);
    481 	SDL_DestroyRenderer(renderer);
    482 	SDL_DestroySurface(surface);
    483 	SDL_Quit();
    484 	return result;
    485 }
    486 
    487 
    488 #else
    489 int
    490 main(void)
    491 {
    492 	fprintf(stderr, "SDL3 not available in this build\n");
    493 	return 1;
    494 }
    495 #endif