phantasia

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

render.c (26083B)


      1 #define _POSIX_C_SOURCE 200809L
      2 #include "engine/battle.h"
      3 #include "engine/area.h"
      4 #include "engine/audio.h"
      5 #include "engine/sdl.h"
      6 #include "game/content.h"
      7 #include "game/presentation.h"
      8 
      9 #include <stdio.h>
     10 #include <stdlib.h>
     11 #include <string.h>
     12 #include <unistd.h>
     13 
     14 #include "config.h"
     15 
     16 #if PH_USE_SDL
     17 static int
     18 ph_camera_smoke(void)
     19 {
     20 	PhWorld world = { .area = { .width = 40, .height = 30 },
     21 		.player_index = 0, .entity_count = 1 };
     22 	PhWorld before;
     23 	PhCamera camera = { .viewport_w = 320, .viewport_h = 240 };
     24 	PhCamera other = { .viewport_w = 160, .viewport_h = 120 };
     25 
     26 	world.entities[0].active = 1;
     27 	world.entities[0].pos = (PhVec2){ 300, 220 };
     28 	memcpy(&before, &world, sizeof(world));
     29 	ph_camera_follow_player(&camera, &world);
     30 	ph_camera_follow_player(&other, &world);
     31 	if (camera.pos.x != 140 || camera.pos.y != 100 ||
     32 			other.pos.x != 220 || other.pos.y != 160 ||
     33 			memcmp(&before, &world, sizeof(world))) return 1;
     34 	world.entities[0].pos = (PhVec2){ 639, 479 };
     35 	ph_camera_follow_player(&camera, &world);
     36 	if (camera.pos.x != 320 || camera.pos.y != 240) return 1;
     37 	world.entities[0].pos = (PhVec2){ 8, 8 };
     38 	ph_camera_follow_player(&camera, &world);
     39 	if (camera.pos.x != 0 || camera.pos.y != 0) return 1;
     40 	world.entities[0].pos = (PhVec2){ 300, 220 };
     41 	camera.viewport_w = 800;
     42 	camera.viewport_h = 600;
     43 	ph_camera_follow_player(&camera, &world);
     44 	if (camera.pos.x != 0 || camera.pos.y != 0) return 1;
     45 	/* A smaller area must discard the previous area's scroll immediately. */
     46 	world.area.width = world.area.height = 4;
     47 	ph_camera_follow_player(&other, &world);
     48 	if (other.pos.x != 0 || other.pos.y != 0) return 1;
     49 	world.player_index = -1;
     50 	camera.pos = (PhVec2){ 100, 100 };
     51 	ph_camera_follow_player(&camera, &world);
     52 	return camera.pos.x != 0 || camera.pos.y != 0;
     53 }
     54 
     55 static int
     56 ph_pixel_is(SDL_Surface *surface, int x, int y, PhColor color)
     57 {
     58 	Uint8 r, g, b, a;
     59 
     60 	return SDL_ReadSurfacePixel(surface, x, y, &r, &g, &b, &a) &&
     61 		r == color.r && g == color.g && b == color.b && a == color.a;
     62 }
     63 
     64 static int
     65 ph_debug_smoke(PhRenderer *renderer, SDL_Surface *surface)
     66 {
     67 	unsigned char tiles[64] = { 0 };
     68 	const PhTileDef defs[] = { { .symbol = '.' }, { .symbol = '#', .blocks_movement = 1 } };
     69 	const PhEntityDef actor = { .id = 1, .kind = PH_ENTITY_MONSTER, .blocks_movement = 1 };
     70 	PhWorld world = {
     71 		.area = { .width = 8, .height = 8, .tiles = tiles, .tile_defs = defs, .tile_def_count = 2 },
     72 		.content = { .entities = &actor, .entity_count = 1 },
     73 		.player_index = -1, .entity_count = 1,
     74 	};
     75 	PhWorld before;
     76 	PhCamera camera = { .pos = { 16, 16 }, .viewport_w = PH_VIEW_W, .viewport_h = PH_VIEW_H };
     77 
     78 	tiles[3 * 8 + 2] = 1;
     79 	world.entities[0] = (PhEntity){ .type_id = 1, .active = 1, .pos = { 96, 96 },
     80 		.motion = { .target = { 112, 112 }, .moving = 1 } };
     81 	memcpy(&before, &world, sizeof(world));
     82 	ph_render_color(renderer, (PhColor){ PH_CLEAR_COLOR });
     83 	ph_render_clear(renderer);
     84 	ph_render_world_debug(renderer, &world, &camera);
     85 	ph_render_present(renderer);
     86 	if (memcmp(&before, &world, sizeof(world)) ||
     87 			!ph_pixel_is(surface, 16, 32, (PhColor){ PH_DEBUG_BLOCK_COLOR }) ||
     88 			!ph_pixel_is(surface, 80 - PH_ENTITY_BLOCK_RADIUS, 80, (PhColor){ PH_DEBUG_ACTOR_COLOR }) ||
     89 			!ph_pixel_is(surface, 96 + PH_ENTITY_BLOCK_RADIUS, 96, (PhColor){ PH_DEBUG_TARGET_COLOR }) ||
     90 			!ph_pixel_is(surface, 94, 94, (PhColor){ PH_DEBUG_TARGET_COLOR })) return 1;
     91 	return 0;
     92 }
     93 
     94 static int
     95 ph_media_smoke(SDL_Renderer *target, SDL_Surface *surface)
     96 {
     97 	char directory[] = "/tmp/ph-media-XXXXXX";
     98 	char path[128], missing[128];
     99 	SDL_Surface *source = NULL;
    100 	PhRenderer *renderer = NULL;
    101 	PhAudio *audio = NULL;
    102 	PhAssetDef defs[2];
    103 	PhAudioDef sounds[3] = { { 0 }, ph_audio_defs[1], { 0 } };
    104 	PhColor background = { 1, 2, 3, 255 };
    105 	PhColor red = { 255, 0, 0, 255 }, green = { 0, 255, 0, 255 };
    106 	PhRect src = { 0, 0, 3, 1 };
    107 	int music = 1, effects = 1, result = 1;
    108 
    109 	if (!mkdtemp(directory)) return 1;
    110 	snprintf(path, sizeof(path), "%s/sprite.bmp", directory);
    111 	snprintf(missing, sizeof(missing), "%s/missing", directory);
    112 	defs[0] = (PhAssetDef){ .path = path, .key = { 255, 0, 255 } };
    113 	defs[1] = (PhAssetDef){ .path = missing };
    114 	sounds[2].path = missing;
    115 	source = SDL_CreateSurface(3, 1, SDL_PIXELFORMAT_RGBA32);
    116 	if (!source || !SDL_WriteSurfacePixel(source, 0, 0, 255, 0, 0, 255) ||
    117 			!SDL_WriteSurfacePixel(source, 1, 0, 0, 255, 0, 255) ||
    118 			!SDL_WriteSurfacePixel(source, 2, 0, 255, 0, 255, 255) ||
    119 			!SDL_SaveBMP(source, path)) goto done;
    120 	/* Fail after loading one asset, then reuse the same borrowed SDL target. */
    121 	renderer = ph_render_sdl_create(target, defs, 2);
    122 	if (renderer) goto done;
    123 	audio = ph_audio_sdl_create(sounds, 3);
    124 	if (audio) goto done;
    125 	renderer = ph_render_sdl_create(target, defs, 1);
    126 	if (!renderer || !ph_render_has_asset(renderer, 0) ||
    127 			ph_render_has_asset(renderer, -1) || ph_render_has_asset(renderer, 1)) goto done;
    128 	ph_render_color(renderer, background);
    129 	ph_render_clear(renderer);
    130 	ph_render_texture(renderer, 0, src, (PhRect){ 2, 2, 3, 1 }, PH_FLIP_NONE);
    131 	ph_render_texture(renderer, 0, src, (PhRect){ 2, 3, 3, 1 }, PH_FLIP_HORIZONTAL);
    132 	ph_render_texture(renderer, -1, src, (PhRect){ 2, 2, 3, 1 }, PH_FLIP_NONE);
    133 	ph_render_texture(renderer, 1, src, (PhRect){ 2, 2, 3, 1 }, PH_FLIP_NONE);
    134 	ph_render_color(renderer, green);
    135 	ph_render_fill(renderer, (PhRect){ 10, 10, 4, 4 });
    136 	ph_render_color(renderer, red);
    137 	ph_render_outline(renderer, (PhRect){ 10, 10, 4, 4 });
    138 	ph_render_present(renderer);
    139 	if (!ph_pixel_is(surface, 2, 2, red) || !ph_pixel_is(surface, 3, 2, green) ||
    140 			!ph_pixel_is(surface, 4, 2, background) ||
    141 			!ph_pixel_is(surface, 2, 3, background) ||
    142 			!ph_pixel_is(surface, 3, 3, green) || !ph_pixel_is(surface, 4, 3, red) ||
    143 			!ph_pixel_is(surface, 10, 10, red) || !ph_pixel_is(surface, 11, 11, green) ||
    144 			!ph_pixel_is(surface, 14, 14, background)) goto done;
    145 	ph_render_destroy(renderer);
    146 	renderer = ph_render_sdl_create(target, NULL, 0);
    147 	if (!renderer || ph_render_has_asset(renderer, 0)) goto done;
    148 	/* Optional audio must be safe when creation fails or audio is disabled. */
    149 	ph_audio_music(NULL, 1);
    150 	ph_audio_effect(NULL, 1, 1.0f);
    151 	ph_audio_set_enabled(NULL, 1, 1);
    152 	ph_audio_update(NULL);
    153 	ph_audio_get_enabled(NULL, &music, &effects);
    154 	if (music || effects) goto done;
    155 	result = 0;
    156 done:
    157 	if (result) fprintf(stderr, "media smoke failed: pixels or resource lifecycle: %s\n", SDL_GetError());
    158 	ph_render_destroy(renderer);
    159 	ph_audio_destroy(audio);
    160 	SDL_DestroySurface(source);
    161 	remove(path);
    162 	rmdir(directory);
    163 	return result;
    164 }
    165 
    166 static int
    167 ph_render_enter_portal(PhWorld *world, int destination_area_id)
    168 {
    169 	int i;
    170 
    171 	for (i = 0; i < world->area.marker_count; ++i) {
    172 		const PhAreaMarker *marker = &world->area.markers[i];
    173 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    174 			world->area_id, marker->symbol);
    175 
    176 		if (!def || def->kind != PH_MARKER_PORTAL ||
    177 				def->destination_area_id != destination_area_id) continue;
    178 		world->entities[world->player_index].pos = (PhVec2){
    179 			PH_TILE_CENTER(marker->tile_x), PH_TILE_CENTER(marker->tile_y) };
    180 		return ph_world_update_portal(world, ph_game_world.areas);
    181 	}
    182 	return -1;
    183 }
    184 
    185 int
    186 main(void)
    187 {
    188 	SDL_Surface *surface;
    189 	SDL_Renderer *target;
    190 	PhRenderer *renderer;
    191 	PhAudio *audio = NULL;
    192 	PhControlLabels labels = {
    193 		.accept = SDL_GetScancodeName(PH_KEY_MENU_ACCEPT),
    194 		.back = SDL_GetScancodeName(PH_KEY_MENU_BACK),
    195 		.cancel = SDL_GetScancodeName(PH_KEY_MENU_CANCEL),
    196 		.character = SDL_GetScancodeName(PH_KEY_CHARACTER),
    197 		.inventory = SDL_GetScancodeName(PH_KEY_INVENTORY),
    198 		.spellbook = SDL_GetScancodeName(PH_KEY_SPELLBOOK),
    199 	};
    200 	PhWorld world;
    201 	PhInventoryUi inventory_ui;
    202 	PhSpellbookUi spellbook_ui;
    203 	PhShopUi shop_ui = { 0 };
    204 	PhOptionsUi options_ui = { .music_enabled = 1, .effects_enabled = 1 };
    205 	PhStartUi start_ui = { .selected = 1 };
    206 	PhGameMode game = PH_GAME_WORLD;
    207 	PhBattle battle = { 0 };
    208 	PhSpellBook spells = ph_player_spellbook;
    209 	PhGameContext context = {
    210 		.world = &world,
    211 		.inventory = &inventory_ui,
    212 		.spellbook = &spellbook_ui,
    213 		.shop = &shop_ui,
    214 		.options = &options_ui,
    215 		.start = &start_ui,
    216 		.mode = &game,
    217 		.battle = &battle,
    218 		.spells = &spells,
    219 	};
    220 	int music_enabled, effects_enabled;
    221 	int i;
    222 	int enemy_index = -1;
    223 	int result = 0;
    224 	int order[PH_BATTLE_ORDER_COUNT];
    225 	PhBattleResult battle_result;
    226 	int first_item;
    227 	int second_item;
    228 
    229 	if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) {
    230 		fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
    231 		return 1;
    232 	}
    233 
    234 	surface = SDL_CreateSurface(PH_VIEW_W, PH_VIEW_H, SDL_PIXELFORMAT_ARGB8888);
    235 	if (!surface) {
    236 		fprintf(stderr, "SDL_CreateSurface failed: %s\n", SDL_GetError());
    237 		SDL_Quit();
    238 		return 1;
    239 	}
    240 
    241 	target = SDL_CreateSoftwareRenderer(surface);
    242 	if (!target) {
    243 		fprintf(stderr, "SDL_CreateSoftwareRenderer failed: %s\n", SDL_GetError());
    244 		SDL_DestroySurface(surface);
    245 		SDL_Quit();
    246 		return 1;
    247 	}
    248 
    249 	result = ph_media_smoke(target, surface);
    250 	if (ph_camera_smoke()) {
    251 		fprintf(stderr, "camera smoke test failed\n");
    252 		result = 1;
    253 	}
    254 	if (ph_world_start(&world, &ph_game_world) < 0) {
    255 		SDL_DestroyRenderer(target);
    256 		SDL_DestroySurface(surface);
    257 		SDL_Quit();
    258 		return 1;
    259 	}
    260 	if (world.ground_item_count < 2) {
    261 		fprintf(stderr, "render smoke test failed: item setup\n");
    262 		ph_world_destroy(&world);
    263 		SDL_DestroyRenderer(target);
    264 		SDL_DestroySurface(surface);
    265 		SDL_Quit();
    266 		return 1;
    267 	}
    268 	first_item = world.ground_items[0].item_id;
    269 	second_item = world.ground_items[1].item_id;
    270 
    271 	if (!(renderer = ph_render_sdl_create(target, ph_asset_defs, PH_ASSET_COUNT))) {
    272 		ph_world_destroy(&world);
    273 		SDL_DestroyRenderer(target);
    274 		SDL_DestroySurface(surface);
    275 		SDL_Quit();
    276 		return 1;
    277 	}
    278 	if (ph_debug_smoke(renderer, surface)) {
    279 		fprintf(stderr, "debug overlay geometry test failed\n");
    280 		result = 1;
    281 	}
    282 	if (ph_start_input(&start_ui, PH_ACTION_ACCEPT) != PH_START_NEW) {
    283 		fprintf(stderr, "render smoke test failed: new-game selection\n");
    284 		result = 1;
    285 	}
    286 	start_ui.can_continue = 1;
    287 	start_ui.selected = 0;
    288 	if (ph_start_input(&start_ui, PH_ACTION_ACCEPT) != PH_START_CONTINUE) {
    289 		fprintf(stderr, "render smoke test failed: continue selection\n");
    290 		result = 1;
    291 	}
    292 	context.menu = PH_MENU_START;
    293 	ph_present_frame(renderer, &context, &labels, NULL);
    294 	{
    295 		PhWorld before;
    296 		PhDebugView debug = { .enabled = 1, .frame_ms = 16, .steps = 1 };
    297 		PhColor baseline;
    298 
    299 		memcpy(&before, &world, sizeof(world));
    300 		SDL_ReadSurfacePixel(surface, 0, 0, &baseline.r, &baseline.g, &baseline.b, &baseline.a);
    301 		ph_present_frame(renderer, &context, &labels, &debug);
    302 		if (!ph_pixel_is(surface, 0, 0, (PhColor){ PH_DEBUG_BG_COLOR }) ||
    303 				memcmp(&before, &world, sizeof(world))) result = 1;
    304 		debug.enabled = 0;
    305 		ph_present_frame(renderer, &context, &labels, &debug);
    306 		if (!ph_pixel_is(surface, 0, 0, baseline)) result = 1;
    307 	}
    308 
    309 	if (!(audio = ph_audio_sdl_create(ph_audio_defs, PH_AUDIO_COUNT))) {
    310 		fprintf(stderr, "audio smoke test failed: %s\n", SDL_GetError());
    311 		ph_render_destroy(renderer);
    312 		ph_world_destroy(&world);
    313 		SDL_DestroyRenderer(target);
    314 		SDL_DestroySurface(surface);
    315 		SDL_Quit();
    316 		return 1;
    317 	}
    318 	ph_audio_music(audio, PH_MUSIC_MEADOW);
    319 	ph_audio_update(audio);
    320 	ph_audio_effect(audio, PH_SOUND_ATTACK, 1.0f);
    321 	ph_audio_effect(audio, PH_SOUND_HIT, 1.0f);
    322 	ph_audio_effect(audio, PH_SOUND_SPELL, 1.0f);
    323 	ph_audio_effect(audio, PH_SOUND_MENU, 0.5f);
    324 	ph_audio_set_enabled(audio, 0, 0);
    325 	ph_audio_get_enabled(audio, &music_enabled, &effects_enabled);
    326 	if (music_enabled || effects_enabled ||
    327 			ph_audio_distance_gain(0.0f, 0.0f, 160.0f, 0.0f, 160.0f) != 0.0f ||
    328 			ph_audio_distance_gain(0.0f, 0.0f, 80.0f, 0.0f, 160.0f) != 0.5f) {
    329 		fprintf(stderr, "audio smoke test failed: controls or distance\n");
    330 		result = 1;
    331 	}
    332 	ph_audio_set_enabled(audio, 1, 1);
    333 	ph_audio_music(audio, PH_MUSIC_TOWN);
    334 	ph_audio_music(audio, PH_MUSIC_BATTLE);
    335 
    336 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    337 	if (ph_world_item_amount(&world, first_item) != 0) {
    338 		fprintf(stderr, "render smoke test failed: adjacent item pickup\n");
    339 		result = 1;
    340 	}
    341 	ph_world_tick(&world, (PhInput){ .move_x = 1 },
    342 		(float)PH_WORLD_STEP_MILLISECONDS / 1000.0f);
    343 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    344 	ph_world_tick(&world, (PhInput){ .move_y = 1 },
    345 		(float)PH_WORLD_STEP_MILLISECONDS / 1000.0f);
    346 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    347 	if (ph_world_item_amount(&world, first_item) != 1 ||
    348 			ph_world_item_amount(&world, second_item) != 1) {
    349 		fprintf(stderr, "render smoke test failed: item pickup\n");
    350 		result = 1;
    351 	}
    352 	ph_inventory_open(&inventory_ui, &world);
    353 	ph_options_open(&options_ui);
    354 	if (ph_options_input(&options_ui, PH_ACTION_DOWN) != PH_OPTIONS_STAY ||
    355 			options_ui.selected != 1 ||
    356 			ph_options_input(&options_ui, PH_ACTION_UP) != PH_OPTIONS_STAY ||
    357 			options_ui.selected != 0 ||
    358 			ph_options_input(&options_ui, PH_ACTION_ACCEPT) != PH_OPTIONS_STAY ||
    359 			options_ui.mode != PH_OPTIONS_SOUND) {
    360 		fprintf(stderr, "render smoke test failed: options navigation\n");
    361 		result = 1;
    362 	}
    363 	ph_options_input(&options_ui, PH_ACTION_ACCEPT);
    364 	ph_options_input(&options_ui, PH_ACTION_DOWN);
    365 	ph_options_input(&options_ui, PH_ACTION_ACCEPT);
    366 	if (options_ui.music_enabled || options_ui.effects_enabled) {
    367 		fprintf(stderr, "render smoke test failed: sound toggles\n");
    368 		result = 1;
    369 	}
    370 	context.menu = PH_MENU_MAIN;
    371 	ph_present_frame(renderer, &context, &labels, NULL);
    372 	ph_options_input(&options_ui, PH_ACTION_BACK);
    373 	if (ph_options_input(&options_ui, PH_ACTION_BACK) != PH_OPTIONS_CLOSE) {
    374 		fprintf(stderr, "render smoke test failed: options cancel\n");
    375 		result = 1;
    376 	}
    377 	ph_options_open(&options_ui);
    378 	ph_options_input(&options_ui, PH_ACTION_DOWN);
    379 	if (ph_options_input(&options_ui, PH_ACTION_ACCEPT) != PH_OPTIONS_EXIT) {
    380 		fprintf(stderr, "render smoke test failed: exit option\n");
    381 		result = 1;
    382 	}
    383 	options_ui.music_enabled = 1;
    384 	options_ui.effects_enabled = 1;
    385 	context.menu = PH_MENU_NONE;
    386 	ph_inventory_input(&inventory_ui, &world, PH_ACTION_ACCEPT);
    387 	if (inventory_ui.option != PH_INVENTORY_EQUIP) result = 1;
    388 	ph_inventory_input(&inventory_ui, &world, PH_ACTION_DOWN);
    389 	if (inventory_ui.option != PH_INVENTORY_DROP) result = 1;
    390 	inventory_ui.mode = PH_INVENTORY_BROWSE;
    391 	ph_inventory_input(&inventory_ui, &world, PH_ACTION_DOWN);
    392 	ph_inventory_input(&inventory_ui, &world, PH_ACTION_ACCEPT);
    393 	if (inventory_ui.option != PH_INVENTORY_USE) result = 1;
    394 	ph_inventory_input(&inventory_ui, &world, PH_ACTION_DOWN);
    395 	if (inventory_ui.option != PH_INVENTORY_DROP) {
    396 		fprintf(stderr, "render smoke test failed: inventory actions\n");
    397 		result = 1;
    398 	}
    399 	inventory_ui.mode = PH_INVENTORY_EXAMINE;
    400 	if (ph_inventory_input(&inventory_ui, &world, PH_ACTION_BACK) ||
    401 			inventory_ui.mode != PH_INVENTORY_ACTIONS ||
    402 			ph_inventory_input(&inventory_ui, &world, PH_ACTION_BACK) ||
    403 			inventory_ui.mode != PH_INVENTORY_BROWSE ||
    404 			!ph_inventory_input(&inventory_ui, &world, PH_ACTION_BACK)) {
    405 		fprintf(stderr, "render smoke test failed: inventory cancel\n");
    406 		result = 1;
    407 	}
    408 	ph_inventory_open(&inventory_ui, &world);
    409 	for (i = 0; i < 10; ++i) {
    410 		inventory_ui.mode = i == 8 ? PH_INVENTORY_EXAMINE :
    411 			i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE;
    412 		context.menu = i == 7 ? PH_MENU_CHARACTER :
    413 				i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE;
    414 		ph_present_frame(renderer, &context, &labels, NULL);
    415 	}
    416 	ph_spellbook_open(&spellbook_ui, &spells);
    417 	ph_spellbook_input(&spellbook_ui, &world, &spells, PH_ACTION_ACCEPT);
    418 	if (spellbook_ui.option != PH_SPELLBOOK_EXAMINE_ACTION) {
    419 		fprintf(stderr, "render smoke test failed: disabled field spell\n");
    420 		result = 1;
    421 	}
    422 	spellbook_ui.mode = PH_SPELLBOOK_BROWSE;
    423 	spellbook_ui.selected = 1;
    424 	ph_spellbook_input(&spellbook_ui, &world, &spells, PH_ACTION_ACCEPT);
    425 	if (spellbook_ui.option != PH_SPELLBOOK_USE) result = 1;
    426 	world.entities[world.player_index].vitals.hp -= 10;
    427 	ph_spellbook_input(&spellbook_ui, &world, &spells, PH_ACTION_ACCEPT);
    428 	if (ph_world_player(&world)->vitals.hp !=
    429 			ph_world_entity_stats(&world, ph_world_player(&world)).max_hp ||
    430 			ph_world_player(&world)->vitals.mp != 40) {
    431 		fprintf(stderr, "render smoke test failed: field Heal\n");
    432 		result = 1;
    433 	}
    434 	world.entities[world.player_index].vitals.mp =
    435 		ph_world_entity_stats(&world, ph_world_player(&world)).max_mp;
    436 	context.menu = PH_MENU_SPELLBOOK;
    437 	ph_present_frame(renderer, &context, &labels, NULL);
    438 	spellbook_ui.mode = PH_SPELLBOOK_ACTIONS;
    439 	ph_present_frame(renderer, &context, &labels, NULL);
    440 	spellbook_ui.mode = PH_SPELLBOOK_EXAMINE;
    441 	ph_present_frame(renderer, &context, &labels, NULL);
    442 	ph_shop_open(&shop_ui, ph_shop_defs[0].id);
    443 	for (i = PH_SHOP_ROOT; i <= PH_SHOP_TALK; ++i) {
    444 		shop_ui.mode = (PhShopMode)i;
    445 		shop_ui.selected = i == PH_SHOP_SELL ?
    446 			ph_inventory_step(&world, -1, 1) : 0;
    447 		context.menu = PH_MENU_SHOP;
    448 		ph_present_frame(renderer, &context, &labels, NULL);
    449 	}
    450 	ph_shop_open(&shop_ui, PH_SHOP_MAGICK);
    451 	shop_ui.mode = PH_SHOP_ROOT;
    452 	ph_present_frame(renderer, &context, &labels, NULL);
    453 	ph_shop_input(&shop_ui, &world, &spells, PH_ACTION_ACCEPT);
    454 	if (shop_ui.mode != PH_SHOP_BUY) result = 1;
    455 	ph_present_frame(renderer, &context, &labels, NULL);
    456 	ph_shop_input(&shop_ui, &world, &spells, PH_ACTION_ACCEPT);
    457 	if (!ph_spellbook_has(&spells, PH_SPELL_EMBER) ||
    458 			ph_world_player(&world)->gold != 16) {
    459 		fprintf(stderr, "render smoke test failed: magick purchase\n");
    460 		result = 1;
    461 	}
    462 	ph_present_frame(renderer, &context, &labels, NULL);
    463 	shop_ui.mode = PH_SHOP_TALK;
    464 	ph_present_frame(renderer, &context, &labels, NULL);
    465 	for (i = 0; i < world.entity_count; ++i) {
    466 		const PhEntityDef *def = ph_world_entity_def(&world, world.entities[i].type_id);
    467 		if (def && def->kind == PH_ENTITY_MONSTER) enemy_index = i;
    468 	}
    469 	if (enemy_index < 0) {
    470 		fprintf(stderr, "render smoke test failed: enemy setup\n");
    471 		result = 1;
    472 	} else {
    473 		world.encounter_index = enemy_index;
    474 		game = PH_GAME_WORLD;
    475 		context.menu = PH_MENU_NONE;
    476 		ph_battle_begin(&battle, &world, enemy_index, spells);
    477 		game = PH_GAME_TRANSITION;
    478 		ph_present_frame(renderer, &context, &labels, NULL);
    479 		if (game != PH_GAME_TRANSITION) result = 1;
    480 		context.menu = PH_MENU_NONE;
    481 		ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS * 0.5f, 1);
    482 		ph_present_frame(renderer, &context, &labels, NULL);
    483 		game = PH_GAME_BATTLE;
    484 		ph_options_open(&options_ui);
    485 		context.menu = PH_MENU_MAIN;
    486 		ph_present_frame(renderer, &context, &labels, NULL);
    487 		context.menu = PH_MENU_NONE;
    488 		battle.phase = PH_BATTLE_ENEMY_ACTION;
    489 		battle.timer = PH_BATTLE_ACTION_SECONDS;
    490 		battle.resolved = 0;
    491 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    492 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_HIT) {
    493 			fprintf(stderr, "render smoke test failed: enemy sound event\n");
    494 			result = 1;
    495 		}
    496 		battle.timer = 0.0f;
    497 		world.entities[world.player_index].vitals.hp = 0;
    498 		context.menu = PH_MENU_NONE;
    499 		battle_result = ph_battle_update(&battle, &world, 0.0f, 0);
    500 		if (battle_result == PH_BATTLE_DEFEAT) game = PH_GAME_OVER;
    501 		ph_present_frame(renderer, &context, &labels, NULL);
    502 		if (game != PH_GAME_OVER) result = 1;
    503 		world.entities[world.player_index].vitals.hp =
    504 			ph_world_entity_stats(&world, ph_world_player(&world)).max_hp;
    505 		ph_battle_begin(&battle, &world, enemy_index, spells);
    506 		game = PH_GAME_TRANSITION;
    507 		context.menu = PH_MENU_NONE;
    508 		if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) ==
    509 				PH_BATTLE_READY) game = PH_GAME_BATTLE;
    510 		ph_present_frame(renderer, &context, &labels, NULL);
    511 		world.entities[enemy_index].vitals.hp =
    512 			ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp;
    513 		world.entities[enemy_index].vitals.hp = 1;
    514 		battle.choice.command = PH_BATTLE_MAGICK;
    515 		ph_battle_select(&battle, &world);
    516 		battle.choice.spell = 1;
    517 		ph_battle_cast(&battle, &world);
    518 		if (battle.phase != PH_BATTLE_TARGET ||
    519 				battle.choice.target_index != world.player_index) result = 1;
    520 		if (ph_battle_target_step(&battle, &world, 1) != enemy_index) result = 1;
    521 		ph_present_frame(renderer, &context, &labels, NULL);
    522 		ph_battle_confirm_target(&battle, &world);
    523 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    524 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_SPELL) {
    525 			fprintf(stderr, "render smoke test failed: healing sound event\n");
    526 			result = 1;
    527 		}
    528 		if (world.entities[enemy_index].vitals.hp !=
    529 				ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp ||
    530 				ph_world_player(&world)->vitals.mp != 40) {
    531 			fprintf(stderr, "render smoke test failed: healing enemy target\n");
    532 			result = 1;
    533 		}
    534 		world.entities[world.player_index].vitals.mp =
    535 			ph_world_entity_stats(&world, ph_world_player(&world)).max_mp;
    536 		ph_battle_begin(&battle, &world, enemy_index, spells);
    537 		ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1);
    538 		battle.choice.command = PH_BATTLE_FIGHT;
    539 		ph_battle_order(&battle, &world, order);
    540 		if (order[0] != world.player_index || order[1] != enemy_index) {
    541 			fprintf(stderr, "render smoke test failed: normal CTB order\n");
    542 			result = 1;
    543 		}
    544 		battle.choice.command = PH_BATTLE_MAGICK;
    545 		ph_battle_order(&battle, &world, order);
    546 		if (order[0] != world.player_index || order[1] != enemy_index) {
    547 			fprintf(stderr, "render smoke test failed: command CTB order\n");
    548 			result = 1;
    549 		}
    550 		ph_battle_select(&battle, &world);
    551 		if (battle.phase != PH_BATTLE_MAGIC || ph_world_player(&world)->vitals.mp != 50) {
    552 			fprintf(stderr, "render smoke test failed: Magick submenu\n");
    553 			result = 1;
    554 		}
    555 		ph_battle_order(&battle, &world, order);
    556 		if (order[0] != world.player_index || order[1] != world.player_index) {
    557 			fprintf(stderr, "render smoke test failed: spell CTB order\n");
    558 			result = 1;
    559 		}
    560 		context.menu = PH_MENU_NONE;
    561 		ph_present_frame(renderer, &context, &labels, NULL);
    562 		ph_battle_cast(&battle, &world);
    563 		if (battle.phase != PH_BATTLE_TARGET ||
    564 				battle.choice.target_index != enemy_index) {
    565 			fprintf(stderr, "render smoke test failed: offensive target default\n");
    566 			result = 1;
    567 		}
    568 		ph_present_frame(renderer, &context, &labels, NULL);
    569 		ph_battle_confirm_target(&battle, &world);
    570 		context.menu = PH_MENU_NONE;
    571 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    572 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_SPELL) {
    573 			fprintf(stderr, "render smoke test failed: spell sound event\n");
    574 			result = 1;
    575 		}
    576 		ph_present_frame(renderer, &context, &labels, NULL);
    577 		context.menu = PH_MENU_NONE;
    578 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    579 		ph_present_frame(renderer, &context, &labels, NULL);
    580 		if (game != PH_GAME_BATTLE || battle.phase != PH_BATTLE_COMMAND ||
    581 				battle.turn.current_actor != world.player_index ||
    582 				ph_world_player(&world)->vitals.mp != 35 ||
    583 				world.entities[enemy_index].accuracy.value != 20 ||
    584 				world.entities[enemy_index].accuracy.turns != 3) {
    585 			fprintf(stderr, "render smoke test failed: Flash action\n");
    586 			result = 1;
    587 		}
    588 		battle.choice.command = PH_BATTLE_FIGHT;
    589 		ph_battle_select(&battle, &world);
    590 		ph_battle_confirm_target(&battle, &world);
    591 		context.menu = PH_MENU_NONE;
    592 		ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    593 		if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_ATTACK) {
    594 			fprintf(stderr, "render smoke test failed: attack sound event\n");
    595 			result = 1;
    596 		}
    597 		ph_present_frame(renderer, &context, &labels, NULL);
    598 		context.menu = PH_MENU_NONE;
    599 		battle_result = ph_battle_update(&battle, &world,
    600 			PH_BATTLE_ACTION_SECONDS * 0.5f, 0);
    601 		if (battle_result == PH_BATTLE_VICTORY) game = PH_GAME_VICTORY;
    602 		ph_present_frame(renderer, &context, &labels, NULL);
    603 		if (game != PH_GAME_VICTORY ||
    604 				ph_world_player(&world)->xp != battle.reward.xp ||
    605 				ph_world_item_amount(&world, battle.reward.item_id) != 2) {
    606 			fprintf(stderr, "render smoke test failed: victory rewards\n");
    607 			result = 1;
    608 		}
    609 	}
    610 	game = PH_GAME_WORLD;
    611 	for (i = 0; i < world.area.marker_count; ++i) {
    612 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    613 			world.area_id, world.area.markers[i].symbol);
    614 
    615 		if (!def || def->kind != PH_MARKER_PORTAL) continue;
    616 		world.entities[world.player_index].pos = (PhVec2){
    617 			PH_TILE_CENTER(world.area.markers[i].tile_x),
    618 			PH_TILE_CENTER(world.area.markers[i].tile_y) };
    619 		break;
    620 	}
    621 	if (i == world.area.marker_count ||
    622 			ph_world_update_portal(&world, ph_game_world.areas) != 1) {
    623 		fprintf(stderr, "render smoke test failed: town portal\n");
    624 		result = 1;
    625 	}
    626 	for (i = 0; i < world.area.marker_count; ++i) {
    627 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    628 			world.area_id, world.area.markers[i].symbol);
    629 
    630 		if (!def || def->kind != PH_MARKER_PORTAL ||
    631 				def->destination_area_id != PH_AREA_MIRA_SHOP) continue;
    632 		world.entities[world.player_index].pos = (PhVec2){
    633 			PH_TILE_CENTER(world.area.markers[i].tile_x),
    634 			PH_TILE_CENTER(world.area.markers[i].tile_y) };
    635 		break;
    636 	}
    637 	if (i == world.area.marker_count ||
    638 			ph_world_update_portal(&world, ph_game_world.areas) != 1 ||
    639 			world.area_id != PH_AREA_MIRA_SHOP) {
    640 		fprintf(stderr, "render smoke test failed: shop portal\n");
    641 		result = 1;
    642 	}
    643 	ph_shop_open(&shop_ui, ph_shop_defs[0].id);
    644 	shop_ui.mode = PH_SHOP_TALK;
    645 	context.menu = PH_MENU_SHOP;
    646 	ph_present_frame(renderer, &context, &labels, NULL);
    647 	if (ph_render_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1) {
    648 		fprintf(stderr, "render smoke test failed: return to town\n");
    649 		result = 1;
    650 	}
    651 	context.menu = PH_MENU_NONE;
    652 	ph_present_frame(renderer, &context, &labels, NULL);
    653 	if (ph_render_enter_portal(&world, PH_AREA_MEADOW) != 1) {
    654 		fprintf(stderr, "render smoke test failed: return to meadow\n");
    655 		result = 1;
    656 	}
    657 	ph_present_frame(renderer, &context, &labels, NULL);
    658 
    659 	if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) {
    660 		fprintf(stderr, "SDL_SaveBMP failed: %s\n", SDL_GetError());
    661 		result = 1;
    662 	}
    663 
    664 	ph_audio_destroy(audio);
    665 	ph_render_destroy(renderer);
    666 	ph_world_destroy(&world);
    667 	SDL_DestroyRenderer(target);
    668 	SDL_DestroySurface(surface);
    669 	SDL_Quit();
    670 	return result;
    671 }
    672 
    673 
    674 #else
    675 int
    676 main(void)
    677 {
    678 	fprintf(stderr, "SDL3 not available in this build\n");
    679 	return 1;
    680 }
    681 #endif