phantasia

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

presentation.c (30023B)


      1 #include "game/presentation.h"
      2 
      3 #include <stdio.h>
      4 #include <string.h>
      5 
      6 #include "config.h"
      7 
      8 #define LEN(a) (sizeof(a) / sizeof(0[a]))
      9 
     10 static float
     11 ph_ui_right(float inset)
     12 {
     13 	return (float)PH_VIEW_W - inset;
     14 }
     15 
     16 static float
     17 ph_ui_bottom(float inset)
     18 {
     19 	return (float)PH_VIEW_H - inset;
     20 }
     21 
     22 static float
     23 ph_ui_center_text(const char *text)
     24 {
     25 	return ((float)PH_VIEW_W - (float)strlen(text) * PH_RENDER_FONT_W) * 0.5f;
     26 }
     27 
     28 static float
     29 ph_draw_wrapped_text(PhRenderer *renderer, float x, float y,
     30 	float width, float line_height, const char *text)
     31 {
     32 	char line[128];
     33 	size_t columns = width > PH_RENDER_FONT_W ? (size_t)(width / PH_RENDER_FONT_W) : 1;
     34 
     35 	while (text && *text) {
     36 		size_t remaining;
     37 		size_t take;
     38 		size_t i;
     39 
     40 		while (*text == ' ') ++text;
     41 		remaining = strlen(text);
     42 		take = remaining < columns ? remaining : columns;
     43 		if (take < remaining)
     44 			for (i = take; i > 0; --i)
     45 				if (text[i] == ' ') {
     46 					take = i;
     47 					break;
     48 				}
     49 		if (take >= sizeof(line)) take = sizeof(line) - 1;
     50 		memcpy(line, text, take);
     51 		line[take] = '\0';
     52 		ph_render_text(renderer, x, y, line);
     53 		text += take;
     54 		y += line_height;
     55 	}
     56 	return y;
     57 }
     58 
     59 static void
     60 ph_append_bonus(char *text, size_t size, size_t *used, int value, const char *label)
     61 {
     62 	int written;
     63 
     64 	if (!value || *used >= size) return;
     65 	written = snprintf(text + *used, size - *used, "%s%+d %s",
     66 		*used ? "  " : "", value, label);
     67 	if (written > 0) *used += (size_t)written;
     68 }
     69 
     70 static void
     71 ph_format_bonuses(char *text, size_t size, PhStats bonuses)
     72 {
     73 	size_t used = 0;
     74 
     75 	text[0] = '\0';
     76 	ph_append_bonus(text, size, &used, bonuses.max_hp, "HP");
     77 	ph_append_bonus(text, size, &used, bonuses.max_mp, "MP");
     78 	ph_append_bonus(text, size, &used, bonuses.strength, "STR");
     79 	ph_append_bonus(text, size, &used, bonuses.defense, "DEF");
     80 	ph_append_bonus(text, size, &used, bonuses.magic, "MAG");
     81 	ph_append_bonus(text, size, &used, bonuses.magic_defense, "MDEF");
     82 	ph_append_bonus(text, size, &used, bonuses.agility, "AGI");
     83 	ph_append_bonus(text, size, &used, bonuses.luck, "LCK");
     84 	ph_append_bonus(text, size, &used, bonuses.evasion, "EVA");
     85 	ph_append_bonus(text, size, &used, bonuses.accuracy, "ACC");
     86 }
     87 
     88 static void
     89 ph_draw_menu(PhRenderer *renderer, const char *title, const char *key, const char *back)
     90 {
     91 	PhRect panel = { 12.0f, 12.0f, PH_VIEW_W - 24.0f, PH_VIEW_H - 24.0f };
     92 	char text[32];
     93 	float x;
     94 
     95 	ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
     96 	ph_render_fill(renderer, panel);
     97 	ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
     98 	ph_render_outline(renderer, panel);
     99 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    100 	ph_render_text(renderer, 24.0f, 22.0f, title);
    101 	snprintf(text, sizeof(text), "CLOSE: %s / %s", key,
    102 		back);
    103 	x = ph_ui_right(20.0f) - (float)strlen(text) * PH_RENDER_FONT_W;
    104 	if (x < 24.0f) x = 24.0f;
    105 	ph_render_text(renderer, x, (float)(PH_VIEW_H - 22), text);
    106 }
    107 
    108 static void
    109 ph_draw_options(PhRenderer *renderer, const PhOptionsUi *ui)
    110 {
    111 	static const char *root_options[] = { "SOUND", "EXIT GAME" };
    112 	PhRect panel = { PH_VIEW_W * 0.5f - 84.0f,
    113 		PH_VIEW_H * 0.5f - 58.0f, 168.0f, 116.0f };
    114 	char text[48];
    115 	int i;
    116 
    117 	if (!ui) return;
    118 	ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
    119 	ph_render_fill(renderer, panel);
    120 	ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
    121 	ph_render_outline(renderer, panel);
    122 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    123 	ph_render_text(renderer, panel.x + 12.0f, panel.y + 10.0f,
    124 		ui->mode == PH_OPTIONS_SOUND ? "SOUND" : "OPTIONS");
    125 	for (i = 0; i < 2; ++i) {
    126 		if (ui->mode == PH_OPTIONS_SOUND)
    127 			snprintf(text, sizeof(text), "%c %-8s %s",
    128 				ui->selected == i ? '>' : ' ', i == 0 ? "MUSIC" : "EFFECTS",
    129 				(i == 0 ? ui->music_enabled : ui->effects_enabled) ? "ON" : "OFF");
    130 		else snprintf(text, sizeof(text), "%c %s",
    131 			ui->selected == i ? '>' : ' ', root_options[i]);
    132 		ph_render_text(renderer, panel.x + 20.0f,
    133 			panel.y + 38.0f + (float)i * 20.0f, text);
    134 	}
    135 	ph_render_text(renderer, panel.x + 12.0f, panel.y + 94.0f,
    136 		"ESC/BKSP BACK");
    137 }
    138 
    139 static void
    140 ph_draw_start(PhRenderer *renderer, const PhStartUi *ui)
    141 {
    142 	PhRect panel = { PH_VIEW_W * 0.5f - 82.0f,
    143 		PH_VIEW_H * 0.5f - 52.0f, 164.0f, 104.0f };
    144 	char text[32];
    145 	int i;
    146 
    147 	if (!ui) return;
    148 	ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
    149 	ph_render_fill(renderer, panel);
    150 	ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
    151 	ph_render_outline(renderer, panel);
    152 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    153 	ph_render_text(renderer, panel.x + 42.0f, panel.y + 12.0f, "PHANTASIA");
    154 	for (i = 0; i < 2; ++i) {
    155 		snprintf(text, sizeof(text), "%c %s", ui->selected == i ? '>' : ' ',
    156 			i == 0 ? "CONTINUE" : "NEW GAME");
    157 		if (i == 0 && !ui->can_continue)
    158 			ph_render_color(renderer, (PhColor){ PH_MENU_DISABLED_COLOR });
    159 		else ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    160 		ph_render_text(renderer, panel.x + 36.0f,
    161 			panel.y + 42.0f + (float)i * 18.0f, text);
    162 	}
    163 }
    164 
    165 static void
    166 ph_draw_character_sheet(PhRenderer *renderer,
    167 	const PhWorld *world, const PhControlLabels *labels)
    168 {
    169 	static const char *slot_names[PH_EQUIP_COUNT] = {
    170 		[PH_EQUIP_WEAPON] = "WEAPON",
    171 		[PH_EQUIP_ARMOR] = "ARMOR",
    172 		[PH_EQUIP_CHARM] = "CHARM",
    173 	};
    174 	const PhEntity *player = ph_world_player(world);
    175 	const PhEntityDef *def;
    176 	PhStats stats;
    177 	int asset_id;
    178 	PhRect avatar = { 32.0f, 46.0f, PH_TILE_SIZE * 3.0f,
    179 		PH_TILE_SIZE * 3.0f };
    180 	char text[128];
    181 	int slot;
    182 
    183 	if (!player || !(def = ph_world_entity_def(world, player->type_id))) return;
    184 	stats = ph_world_entity_stats(world, player);
    185 	ph_draw_menu(renderer, "CHARACTER", labels->character, labels->back);
    186 	ph_render_text(renderer, 104.0f, 38.0f, def->name);
    187 
    188 	asset_id = def->asset_id;
    189 	if (ph_render_has_asset(renderer, asset_id))
    190 		ph_render_sprite(renderer, asset_id,
    191 			def->sprite_tiles[PH_SPRITE_DOWN][0][0],
    192 			def->sprite_tiles[PH_SPRITE_DOWN][0][1],
    193 			avatar, PH_FLIP_NONE);
    194 
    195 	snprintf(text, sizeof(text), "HP      %d / %d", player->vitals.hp, stats.max_hp);
    196 	ph_render_text(renderer, 104.0f, 54.0f, text);
    197 	snprintf(text, sizeof(text), "MP      %d / %d", player->vitals.mp, stats.max_mp);
    198 	ph_render_text(renderer, 104.0f, 66.0f, text);
    199 	snprintf(text, sizeof(text), "STR     %d", stats.strength);
    200 	ph_render_text(renderer, 104.0f, 78.0f, text);
    201 	snprintf(text, sizeof(text), "DEF     %d", stats.defense);
    202 	ph_render_text(renderer, 104.0f, 90.0f, text);
    203 	snprintf(text, sizeof(text), "MAG  %d", stats.magic);
    204 	ph_render_text(renderer, ph_ui_right(88.0f), 54.0f, text);
    205 	snprintf(text, sizeof(text), "MDEF %d", stats.magic_defense);
    206 	ph_render_text(renderer, ph_ui_right(88.0f), 66.0f, text);
    207 	snprintf(text, sizeof(text), "AGI  %d", stats.agility);
    208 	ph_render_text(renderer, ph_ui_right(88.0f), 78.0f, text);
    209 	snprintf(text, sizeof(text), "LCK  %d", stats.luck);
    210 	ph_render_text(renderer, ph_ui_right(88.0f), 90.0f, text);
    211 	snprintf(text, sizeof(text), "EVA  %d", stats.evasion);
    212 	ph_render_text(renderer, ph_ui_right(88.0f), 102.0f, text);
    213 	snprintf(text, sizeof(text), "ACC  %d", stats.accuracy);
    214 	ph_render_text(renderer, ph_ui_right(88.0f), 114.0f, text);
    215 	ph_render_text(renderer, 24.0f, 128.0f, "EQUIPPED");
    216 
    217 	for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) {
    218 		const PhItemDef *item = ph_world_item_def(world, player->equipment[slot]);
    219 		int icon_id;
    220 		float y = 144.0f + (float)(slot - PH_EQUIP_WEAPON) * 25.0f;
    221 
    222 		snprintf(text, sizeof(text), "%s  %s", slot_names[slot], item ? item->name : "-");
    223 		ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    224 		ph_render_text(renderer, 48.0f, y, text);
    225 		if (!item) continue;
    226 		icon_id = item->asset_id;
    227 		if (ph_render_has_asset(renderer, icon_id)) {
    228 			PhRect icon = { 24.0f, y - 4.0f, PH_TILE_SIZE, PH_TILE_SIZE };
    229 			ph_render_sprite(renderer, icon_id,
    230 				item->sprite_tile_x, item->sprite_tile_y, icon, PH_FLIP_NONE);
    231 		}
    232 		ph_format_bonuses(text, sizeof(text), item->bonuses);
    233 		ph_render_color(renderer, (PhColor){ PH_MENU_BONUS_COLOR });
    234 		ph_render_text(renderer, 48.0f, y + 10.0f, text);
    235 	}
    236 }
    237 
    238 static void
    239 ph_draw_inventory_popup(PhRenderer *renderer, const PhWorld *world,
    240 	const PhInventoryUi *ui)
    241 {
    242 	static const char *action_names[] = { "EQUIP", "USE", "DROP", "EXAMINE" };
    243 	const PhItemDef *item = &world->content.items[ui->selected];
    244 	int actions[PH_INVENTORY_ACTION_COUNT];
    245 	PhRect popup = ui->mode == PH_INVENTORY_EXAMINE ?
    246 		(PhRect){ 40.0f, 48.0f, PH_VIEW_W - 64.0f, 124.0f } :
    247 		(PhRect){ PH_VIEW_W - 164.0f, 48.0f, 140.0f, 124.0f };
    248 	char text[128];
    249 	float x = ui->mode == PH_INVENTORY_EXAMINE ? 52.0f : ph_ui_right(152.0f);
    250 	int i;
    251 
    252 	ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
    253 	ph_render_fill(renderer, popup);
    254 	ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
    255 	ph_render_outline(renderer, popup);
    256 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    257 	if (ui->mode == PH_INVENTORY_EXAMINE) {
    258 		ph_render_text(renderer, x, 58.0f,
    259 			item->name ? item->name : "ITEM");
    260 		ph_render_text(renderer, x, 76.0f,
    261 			item->description ? item->description : "No description.");
    262 		snprintf(text, sizeof(text), "SELL %d G", item->sell_value);
    263 		ph_render_text(renderer, x, 96.0f, text);
    264 		ph_format_bonuses(text, sizeof(text), item->bonuses);
    265 		if (item->use == PH_ITEM_USE_HEAL)
    266 			snprintf(text, sizeof(text), "HEALS %d HP", item->use_power);
    267 		else if (item->use == PH_ITEM_USE_RESTORE_MP)
    268 			snprintf(text, sizeof(text), "RESTORES %d MP", item->use_power);
    269 		if (text[0]) ph_render_text(renderer, x, 112.0f, text);
    270 		ph_render_text(renderer, x, 148.0f, "ENTER BACK");
    271 		return;
    272 	}
    273 
    274 	ph_render_text(renderer, ph_ui_right(152.0f), 58.0f, "ITEM ACTION");
    275 	ph_inventory_action_order(item, actions);
    276 	for (i = 0; i < PH_INVENTORY_ACTION_COUNT; ++i) {
    277 		int action = actions[i];
    278 
    279 		if (ph_inventory_action_enabled(item, action))
    280 			ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    281 		else
    282 			ph_render_color(renderer, (PhColor){ PH_MENU_DISABLED_COLOR });
    283 		snprintf(text, sizeof(text), "%c %s",
    284 			action == ui->option ? '>' : ' ', action_names[action]);
    285 		ph_render_text(renderer, ph_ui_right(152.0f),
    286 			78.0f + (float)i * 18.0f, text);
    287 	}
    288 }
    289 
    290 static void
    291 ph_draw_inventory(PhRenderer *renderer,
    292 	const PhWorld *world, const PhInventoryUi *ui, const PhControlLabels *labels)
    293 {
    294 	const PhEntity *player = ph_world_player(world);
    295 	char text[128];
    296 	int selected_rank = 0;
    297 	int total = 0;
    298 	int rank = 0;
    299 	int page;
    300 	int i;
    301 
    302 	for (i = 0; i < world->content.item_count; ++i)
    303 		if (world->inventory[i] > 0) {
    304 			if (i == ui->selected) selected_rank = total;
    305 			++total;
    306 		}
    307 	page = selected_rank / 5;
    308 	ph_draw_menu(renderer, "INVENTORY", labels->inventory, labels->back);
    309 	if (player) {
    310 		snprintf(text, sizeof(text), "GOLD %d", player->gold);
    311 		ph_render_text(renderer, ph_ui_right(96.0f), 22.0f, text);
    312 	}
    313 	for (i = 0; i < world->content.item_count; ++i) {
    314 		const PhItemDef *item = &world->content.items[i];
    315 		int asset_id;
    316 		float y;
    317 
    318 		if (world->inventory[i] <= 0) continue;
    319 		if (rank < page * 5 || rank >= page * 5 + 5) {
    320 			++rank;
    321 			continue;
    322 		}
    323 		y = 48.0f + (float)(rank++ - page * 5) * 27.0f;
    324 		ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    325 		ph_render_text(renderer, 20.0f, y, i == ui->selected ? ">" : " ");
    326 		asset_id = item->asset_id;
    327 		if (ph_render_has_asset(renderer, asset_id)) {
    328 			PhRect icon = { 32.0f, y - 4.0f, PH_TILE_SIZE, PH_TILE_SIZE };
    329 			ph_render_sprite(renderer, asset_id,
    330 				item->sprite_tile_x, item->sprite_tile_y, icon, PH_FLIP_NONE);
    331 		}
    332 		snprintf(text, sizeof(text), "%s  x%d",
    333 			item->name ? item->name : "Unknown", world->inventory[i]);
    334 		ph_render_text(renderer, 56.0f, y, text);
    335 		ph_format_bonuses(text, sizeof(text), item->bonuses);
    336 		if (!text[0] && item->use == PH_ITEM_USE_HEAL)
    337 			snprintf(text, sizeof(text), "HEALS %d HP", item->use_power);
    338 		if (!text[0] && item->use == PH_ITEM_USE_RESTORE_MP)
    339 			snprintf(text, sizeof(text), "RESTORES %d MP", item->use_power);
    340 		if (!text[0]) snprintf(text, sizeof(text), "SELL %d G", item->sell_value);
    341 		ph_render_color(renderer, (PhColor){ PH_MENU_BONUS_COLOR });
    342 		ph_render_text(renderer, 56.0f, y + 10.0f, text);
    343 	}
    344 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    345 	if (!total) ph_render_text(renderer, 24.0f, 48.0f, "EMPTY");
    346 	if (total > 5) {
    347 		snprintf(text, sizeof(text), "PAGE %d/%d", page + 1, (total + 4) / 5);
    348 		ph_render_text(renderer, 24.0f, ph_ui_bottom(56.0f), text);
    349 	}
    350 	if (ui->status[0])
    351 		ph_render_text(renderer, 24.0f, ph_ui_bottom(40.0f), ui->status);
    352 	snprintf(text, sizeof(text), "%s SELECT",
    353 		labels->accept);
    354 	ph_render_text(renderer, 24.0f, ph_ui_bottom(24.0f), text);
    355 	if (ui->selected >= 0 && ui->mode != PH_INVENTORY_BROWSE)
    356 		ph_draw_inventory_popup(renderer, world, ui);
    357 }
    358 
    359 static void
    360 ph_draw_spellbook_popup(PhRenderer *renderer, const PhSpellBook *spells,
    361 	const PhSpellbookUi *ui)
    362 {
    363 	const PhSpellDef *spell = ph_spellbook_def(spells,
    364 		spells->learned[ui->selected]);
    365 	PhRect popup = ui->mode == PH_SPELLBOOK_EXAMINE ?
    366 		(PhRect){ 40.0f, 48.0f, PH_VIEW_W - 64.0f, 124.0f } :
    367 		(PhRect){ PH_VIEW_W - 164.0f, 48.0f, 140.0f, 82.0f };
    368 	char text[96];
    369 	int action;
    370 
    371 	if (!spell) return;
    372 	ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
    373 	ph_render_fill(renderer, popup);
    374 	ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
    375 	ph_render_outline(renderer, popup);
    376 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    377 	if (ui->mode == PH_SPELLBOOK_EXAMINE) {
    378 		ph_render_text(renderer, 52.0f, 58.0f, spell->name);
    379 		ph_draw_wrapped_text(renderer, 52.0f, 76.0f, popup.w - 24.0f, 12.0f,
    380 			spell->description ? spell->description : "No description.");
    381 		snprintf(text, sizeof(text), "%d MP   POWER %d   DELAY %d%%",
    382 			spell->mp_cost, spell->power, spell->delay_percent);
    383 		ph_render_text(renderer, 52.0f, 126.0f, text);
    384 		ph_render_text(renderer, 52.0f, 148.0f, "ENTER BACK");
    385 		return;
    386 	}
    387 	ph_render_text(renderer, ph_ui_right(152.0f), 58.0f, "SPELL ACTION");
    388 	for (action = 0; action < PH_SPELLBOOK_ACTION_COUNT; ++action) {
    389 		if (ph_spellbook_action_enabled(spell, action))
    390 			ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    391 		else
    392 			ph_render_color(renderer, (PhColor){ PH_MENU_DISABLED_COLOR });
    393 		snprintf(text, sizeof(text), "%c %s", ui->option == action ? '>' : ' ',
    394 			action == PH_SPELLBOOK_USE ? "USE" : "EXAMINE");
    395 		ph_render_text(renderer, ph_ui_right(152.0f),
    396 			78.0f + (float)action * 18.0f, text);
    397 	}
    398 }
    399 
    400 static void
    401 ph_draw_spellbook(PhRenderer *renderer, const PhWorld *world,
    402 	const PhSpellBook *spells, const PhSpellbookUi *ui, const PhControlLabels *labels)
    403 {
    404 	const PhEntity *player = ph_world_player(world);
    405 	PhStats stats = ph_world_entity_stats(world, player);
    406 	char text[96];
    407 	int page = ui->selected < 0 ? 0 : ui->selected / 6;
    408 	int i;
    409 
    410 	ph_draw_menu(renderer, "MAGICK BOOK", labels->spellbook, labels->back);
    411 	if (player) {
    412 		snprintf(text, sizeof(text), "MP %d/%d", player->vitals.mp, stats.max_mp);
    413 		ph_render_text(renderer, ph_ui_right(96.0f), 22.0f, text);
    414 	}
    415 	for (i = page * 6; i < spells->learned_count && i < page * 6 + 6; ++i) {
    416 		const PhSpellDef *spell = ph_spellbook_def(spells, spells->learned[i]);
    417 		float y = 48.0f + (float)(i - page * 6) * 24.0f;
    418 
    419 		if (!spell) continue;
    420 		ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    421 		snprintf(text, sizeof(text), "%c %-14s %2d MP",
    422 			ui->selected == i ? '>' : ' ', spell->name, spell->mp_cost);
    423 		ph_render_text(renderer, 24.0f, y, text);
    424 		ph_render_color(renderer, (PhColor){ PH_MENU_BONUS_COLOR });
    425 		snprintf(text, sizeof(text), "%s  POWER %d  DELAY %d%%",
    426 			spell->field_use != PH_SPELL_FIELD_NONE ? "FIELD" : "BATTLE",
    427 			spell->power, spell->delay_percent);
    428 		ph_render_text(renderer, 40.0f, y + 10.0f, text);
    429 	}
    430 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    431 	if (spells->learned_count <= 0)
    432 		ph_render_text(renderer, 24.0f, 48.0f, "NO SPELLS LEARNED");
    433 	if (spells->learned_count > 6) {
    434 		snprintf(text, sizeof(text), "PAGE %d/%d", page + 1,
    435 			(spells->learned_count + 5) / 6);
    436 		ph_render_text(renderer, 24.0f, ph_ui_bottom(56.0f), text);
    437 	}
    438 	if (ui->status[0])
    439 		ph_render_text(renderer, 24.0f, ph_ui_bottom(40.0f), ui->status);
    440 	ph_render_text(renderer, 24.0f, ph_ui_bottom(24.0f), "ENTER SELECT");
    441 	if (ui->selected >= 0 && ui->mode != PH_SPELLBOOK_BROWSE)
    442 		ph_draw_spellbook_popup(renderer, spells, ui);
    443 }
    444 
    445 static void
    446 ph_draw_shop(PhRenderer *renderer, const PhWorld *world,
    447 	const PhSpellBook *spells, const PhShopUi *ui, const PhControlLabels *labels)
    448 {
    449 	static const char *item_options[] = { "BUY", "SELL", "TALK" };
    450 	static const char *magick_options[] = { "LEARN", "TALK" };
    451 	const PhShopDef *shop = ph_game_shop_def(ui->shop_id);
    452 	const PhEntity *player = ph_world_player(world);
    453 	char text[128];
    454 	int i;
    455 
    456 	if (!shop || !player) return;
    457 	ph_draw_menu(renderer, shop->name, labels->cancel, labels->back);
    458 	snprintf(text, sizeof(text), "GOLD %d", player->gold);
    459 	ph_render_text(renderer, ph_ui_right(96.0f), 38.0f, text);
    460 	if (ui->mode == PH_SHOP_ROOT) {
    461 		const char **options = shop->kind == PH_SHOP_ITEMS ?
    462 			item_options : magick_options;
    463 		int option_count = shop->kind == PH_SHOP_ITEMS ?
    464 			(int)LEN(item_options) : (int)LEN(magick_options);
    465 
    466 		for (i = 0; i < option_count; ++i) {
    467 			snprintf(text, sizeof(text), "%c %s", ui->selected == i ? '>' : ' ',
    468 				options[i]);
    469 			ph_render_text(renderer, 32.0f, 56.0f + (float)i * 20.0f, text);
    470 		}
    471 		ph_render_text(renderer, 32.0f, 132.0f,
    472 			shop->kind == PH_SHOP_ITEMS ? "Welcome, traveler." :
    473 			"Choose your lesson.");
    474 	} else if (ui->mode == PH_SHOP_BUY) {
    475 		ph_render_text(renderer, 24.0f, 42.0f,
    476 			shop->kind == PH_SHOP_ITEMS ? "BUY" : "MAGICK");
    477 		for (i = 0; i < shop->stock_count; ++i) {
    478 			if (shop->kind == PH_SHOP_SPELLS) {
    479 				const PhSpellDef *spell = ph_spellbook_def(spells, shop->stock[i]);
    480 
    481 				if (!spell) continue;
    482 				if (ph_spellbook_has(spells, spell->id))
    483 					snprintf(text, sizeof(text), "%c %-12s LEARNED",
    484 						ui->selected == i ? '>' : ' ', spell->name);
    485 				else
    486 					snprintf(text, sizeof(text), "%c %-12s %4d G %2d MP",
    487 						ui->selected == i ? '>' : ' ', spell->name,
    488 						spell->buy_value, spell->mp_cost);
    489 			} else {
    490 				const PhItemDef *item = ph_world_item_def(world, shop->stock[i]);
    491 
    492 				if (!item) continue;
    493 				snprintf(text, sizeof(text), "%c %-18s %d G",
    494 					ui->selected == i ? '>' : ' ', item->name, item->buy_value);
    495 			}
    496 			ph_render_text(renderer, 24.0f, 62.0f + (float)i * 20.0f, text);
    497 		}
    498 	} else if (ui->mode == PH_SHOP_SELL) {
    499 		int row = 0;
    500 
    501 		ph_render_text(renderer, 24.0f, 42.0f, "SELL");
    502 		for (i = 0; i < world->content.item_count; ++i) {
    503 			const PhItemDef *item = &world->content.items[i];
    504 
    505 			if (world->inventory[i] <= 0) continue;
    506 			snprintf(text, sizeof(text), "%c %-16s x%d  %d G",
    507 				ui->selected == i ? '>' : ' ', item->name, world->inventory[i],
    508 				item->sell_value);
    509 			ph_render_text(renderer, 24.0f, 62.0f + (float)row++ * 18.0f, text);
    510 		}
    511 		if (!row) ph_render_text(renderer, 24.0f, 62.0f, "Nothing to sell.");
    512 	} else {
    513 		float y = 48.0f;
    514 		float width = ph_ui_right(24.0f) - 24.0f;
    515 
    516 		for (i = 0; i < PH_SHOP_TALK_LINES; ++i) {
    517 			y = ph_draw_wrapped_text(renderer, 24.0f, y, width, 12.0f,
    518 				shop->talk[i]);
    519 			y += 6.0f;
    520 		}
    521 		ph_render_text(renderer, 24.0f, y + 8.0f, "ENTER / ESC BACK");
    522 	}
    523 	if (ui->status[0])
    524 		ph_render_text(renderer, 24.0f, ph_ui_bottom(36.0f), ui->status);
    525 }
    526 
    527 static void
    528 ph_draw_battle_entity(PhRenderer *renderer,
    529 	const PhWorld *world, int entity_index, PhRect rect)
    530 {
    531 	const PhEntity *entity = &world->entities[entity_index];
    532 	const PhEntityDef *def = ph_world_entity_def(world, entity->type_id);
    533 	int asset_id = -1;
    534 	int x;
    535 	int y;
    536 	PhFlip flip;
    537 
    538 	if (!def) return;
    539 	asset_id = def->asset_id;
    540 	if (def->kind == PH_ENTITY_PLAYER) {
    541 		x = def->sprite_tiles[PH_SPRITE_SIDE][0][0];
    542 		y = def->sprite_tiles[PH_SPRITE_SIDE][0][1];
    543 		flip = PH_FLIP_NONE;
    544 	} else {
    545 		ph_render_entity_frame(def, entity, &x, &y, &flip);
    546 	}
    547 	if (ph_render_has_asset(renderer, asset_id)) ph_render_sprite(renderer, asset_id, x, y, rect, flip);
    548 	else {
    549 		if (def->kind == PH_ENTITY_MONSTER)
    550 			ph_render_color(renderer, (PhColor){ PH_MONSTER_COLOR });
    551 		else
    552 			ph_render_color(renderer, (PhColor){ PH_NPC_COLOR });
    553 		ph_render_fill(renderer, rect);
    554 	}
    555 }
    556 
    557 static void
    558 ph_draw_battle(PhRenderer *renderer,
    559 	const PhWorld *world, const PhBattle *battle)
    560 {
    561 	static const char *commands[PH_BATTLE_COMMAND_COUNT] = {
    562 		[PH_BATTLE_FIGHT] = "FIGHT",
    563 		[PH_BATTLE_MAGICK] = "MAGICK",
    564 		[PH_BATTLE_ITEM] = "ITEM",
    565 	};
    566 	const PhEntity *player = ph_world_player(world);
    567 	const PhEntity *enemy = &world->entities[battle->enemy_index];
    568 	const PhEntityDef *enemy_def = ph_world_entity_def(world, enemy->type_id);
    569 	PhStats player_stats = ph_world_entity_stats(world, player);
    570 	PhStats enemy_stats = ph_world_entity_stats(world, enemy);
    571 	int order[PH_BATTLE_ORDER_COUNT];
    572 	PhRect ground = { 0.0f, PH_VIEW_H - 136.0f, PH_VIEW_W, 72.0f };
    573 	PhRect panel = { 8.0f, PH_VIEW_H - 64.0f, PH_VIEW_W - 16.0f, 56.0f };
    574 	PhRect spell_panel = { PH_VIEW_W * 0.5f - 88.0f,
    575 		PH_VIEW_H - 116.0f, 176.0f,
    576 		24.0f + (float)battle->spells.learned_count * 12.0f };
    577 	PhRect enemy_rect = { 56.0f + ph_battle_lunge(battle,
    578 		PH_BATTLE_ENEMY_ACTION), PH_VIEW_H - 172.0f,
    579 		PH_TILE_SIZE * 3.0f, PH_TILE_SIZE * 3.0f };
    580 	PhRect player_rect = { PH_VIEW_W - 88.0f - ph_battle_lunge(battle,
    581 		PH_BATTLE_PLAYER_ACTION), PH_VIEW_H - 120.0f,
    582 		PH_TILE_SIZE * 3.0f, PH_TILE_SIZE * 3.0f };
    583 	char text[96];
    584 	int i;
    585 
    586 	ph_render_color(renderer, (PhColor){ PH_BATTLE_BG_COLOR });
    587 	ph_render_clear(renderer);
    588 	ph_render_color(renderer, (PhColor){ PH_BATTLE_GROUND_COLOR });
    589 	ph_render_fill(renderer, ground);
    590 	ph_draw_battle_entity(renderer, world, battle->enemy_index, enemy_rect);
    591 	ph_draw_battle_entity(renderer, world, world->player_index, player_rect);
    592 	if (battle->phase == PH_BATTLE_TARGET) {
    593 		PhRect target_rect = battle->choice.target_index == world->player_index ?
    594 			player_rect : enemy_rect;
    595 
    596 		target_rect.x -= 3.0f;
    597 		target_rect.y -= 3.0f;
    598 		target_rect.w += 6.0f;
    599 		target_rect.h += 6.0f;
    600 		ph_render_color(renderer, (PhColor){ PH_MENU_BONUS_COLOR });
    601 		ph_render_outline(renderer, target_rect);
    602 	}
    603 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    604 	ph_render_text(renderer, 16.0f, 18.0f,
    605 		enemy_def && enemy_def->name ? enemy_def->name : "ENEMY");
    606 	snprintf(text, sizeof(text), "HP %d/%d", enemy->vitals.hp, enemy_stats.max_hp);
    607 	ph_render_text(renderer, 16.0f, 30.0f, text);
    608 	if (enemy->accuracy.turns > 0) {
    609 		snprintf(text, sizeof(text), "ACC -%d%% (%d)", enemy->accuracy.value,
    610 			enemy->accuracy.turns);
    611 		ph_render_text(renderer, 16.0f, 42.0f, text);
    612 	}
    613 	ph_battle_order(battle, world, order);
    614 	ph_render_text(renderer, ph_ui_right(96.0f), 18.0f, "TURN ORDER");
    615 	for (i = 0; i < PH_BATTLE_ORDER_COUNT; ++i) {
    616 		snprintf(text, sizeof(text), "%d %s", i + 1,
    617 			order[i] == world->player_index ? "YOU" : "ENEMY");
    618 		ph_render_text(renderer, ph_ui_right(80.0f),
    619 			30.0f + (float)i * 11.0f, text);
    620 	}
    621 	ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
    622 	ph_render_fill(renderer, panel);
    623 	ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
    624 	ph_render_outline(renderer, panel);
    625 	for (i = 0; i < (int)LEN(commands); ++i) {
    626 		ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    627 		snprintf(text, sizeof(text), "%c %s",
    628 			(battle->phase == PH_BATTLE_COMMAND || battle->phase == PH_BATTLE_MAGIC ||
    629 				battle->phase == PH_BATTLE_TARGET) &&
    630 				battle->choice.command == (PhBattleCommand)i ? '>' : ' ',
    631 			commands[i]);
    632 		ph_render_text(renderer, 18.0f,
    633 			panel.y + 8.0f + (float)i * 14.0f, text);
    634 	}
    635 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    636 	ph_render_text(renderer, 104.0f, panel.y + 8.0f, battle->message);
    637 	if (battle->phase == PH_BATTLE_TARGET) {
    638 		const PhEntityDef *target_def = ph_world_entity_def(world,
    639 			world->entities[battle->choice.target_index].type_id);
    640 
    641 		snprintf(text, sizeof(text), "TARGET: %s   ENTER CONFIRM",
    642 			target_def && target_def->name ? target_def->name : "UNKNOWN");
    643 		ph_render_text(renderer, 104.0f, panel.y + 24.0f, text);
    644 	}
    645 	snprintf(text, sizeof(text), "HP %d/%d", player->vitals.hp, player_stats.max_hp);
    646 	ph_render_text(renderer, ph_ui_right(80.0f), ph_ui_bottom(36.0f), text);
    647 	snprintf(text, sizeof(text), "MP %d/%d", player->vitals.mp, player_stats.max_mp);
    648 	ph_render_text(renderer, ph_ui_right(80.0f), ph_ui_bottom(24.0f), text);
    649 	if (battle->phase == PH_BATTLE_MAGIC) {
    650 		ph_render_color(renderer, (PhColor){ PH_MENU_COLOR });
    651 		ph_render_fill(renderer, spell_panel);
    652 		ph_render_color(renderer, (PhColor){ PH_MENU_BORDER_COLOR });
    653 		ph_render_outline(renderer, spell_panel);
    654 		ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    655 		ph_render_text(renderer, spell_panel.x + 8.0f,
    656 			spell_panel.y + 6.0f, "MAGICK");
    657 		for (i = 0; i < battle->spells.learned_count; ++i) {
    658 			const PhSpellDef *choice = ph_spell_def(battle,
    659 				battle->spells.learned[i]);
    660 
    661 			if (!choice) continue;
    662 			if (ph_spell_battle_usable(choice))
    663 				ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    664 			else
    665 				ph_render_color(renderer, (PhColor){ PH_MENU_DISABLED_COLOR });
    666 			snprintf(text, sizeof(text), "%c %s %dMP%s",
    667 				battle->choice.spell == i ? '>' : ' ', choice->name, choice->mp_cost,
    668 				choice->delay_percent < 100 ? " QUICK" : "");
    669 			ph_render_text(renderer, spell_panel.x + 8.0f,
    670 				spell_panel.y + 20.0f + (float)i * 12.0f, text);
    671 		}
    672 	}
    673 }
    674 
    675 static void
    676 ph_draw_battle_end(PhRenderer *renderer, const PhWorld *world,
    677 	const PhBattle *battle, PhGameMode game)
    678 {
    679 	const PhItemDef *loot = ph_world_item_def(world, battle->reward.item_id);
    680 	char text[96];
    681 
    682 	if (game == PH_GAME_VICTORY)
    683 		ph_render_color(renderer, (PhColor){ PH_BATTLE_BG_COLOR });
    684 	else
    685 		ph_render_color(renderer, (PhColor){ PH_CLEAR_COLOR });
    686 	ph_render_clear(renderer);
    687 	ph_render_color(renderer, (PhColor){ PH_MENU_TEXT_COLOR });
    688 	if (game == PH_GAME_OVER) {
    689 		ph_render_text(renderer, ph_ui_center_text("GAME OVER"),
    690 			PH_VIEW_H * 0.5f - 12.0f, "GAME OVER");
    691 		return;
    692 	}
    693 	ph_render_text(renderer, ph_ui_center_text("VICTORY!"),
    694 		PH_VIEW_H * 0.5f - 44.0f, "VICTORY!");
    695 	snprintf(text, sizeof(text), "XP GAINED  %d", battle->reward.xp);
    696 	ph_render_text(renderer, ph_ui_center_text(text),
    697 		PH_VIEW_H * 0.5f - 14.0f, text);
    698 	if (loot && battle->reward.amount > 0)
    699 		snprintf(text, sizeof(text), "LOOT  %s x%d", loot->name,
    700 			battle->reward.amount);
    701 	else
    702 		snprintf(text, sizeof(text), "LOOT  NONE");
    703 	ph_render_text(renderer, ph_ui_center_text(text),
    704 		PH_VIEW_H * 0.5f + 4.0f, text);
    705 	ph_render_text(renderer, ph_ui_center_text("ENTER TO CONTINUE"),
    706 		PH_VIEW_H * 0.5f + 44.0f, "ENTER TO CONTINUE");
    707 }
    708 
    709 static void
    710 ph_draw_debug_stats(PhRenderer *renderer, const PhWorld *world,
    711 	const PhDebugView *debug)
    712 {
    713 	char text[96];
    714 
    715 	if (!debug || !debug->enabled) return;
    716 	ph_render_color(renderer, (PhColor){ PH_DEBUG_BG_COLOR });
    717 	ph_render_fill(renderer, (PhRect){ 0, 0, PH_VIEW_W, 40 });
    718 	ph_render_color(renderer, (PhColor){ PH_DEBUG_TEXT_COLOR });
    719 	snprintf(text, sizeof(text), "FRAME %.2f MS  TICKS %d", debug->frame_ms, debug->steps);
    720 	ph_render_text(renderer, 4, 2, text);
    721 	snprintf(text, sizeof(text), "UPDATE %.2f  DRAW %.2f MS", debug->update_ms, debug->draw_ms);
    722 	ph_render_text(renderer, 4, 14, text);
    723 	snprintf(text, sizeof(text), "EVENTS %d/%d  LOST %u", world->event_count,
    724 		PH_MAX_WORLD_EVENTS, world->event_dropped);
    725 	ph_render_text(renderer, 4, 26, text);
    726 }
    727 
    728 void
    729 ph_present_frame(PhRenderer *renderer,
    730 	const PhGameContext *context, const PhControlLabels *labels, const PhDebugView *debug)
    731 {
    732 	const PhWorld *world = context->world;
    733 	const PhBattle *battle = context->battle;
    734 	PhGameMode game = *context->mode;
    735 	PhCamera camera = { .viewport_w = PH_VIEW_W, .viewport_h = PH_VIEW_H };
    736 
    737 	if (game == PH_GAME_BATTLE) {
    738 		ph_draw_battle(renderer, world, battle);
    739 		if (context->menu == PH_MENU_MAIN)
    740 			ph_draw_options(renderer, context->options);
    741 		ph_draw_debug_stats(renderer, world, debug);
    742 		ph_render_present(renderer);
    743 		return;
    744 	}
    745 	if (game == PH_GAME_VICTORY || game == PH_GAME_OVER) {
    746 		ph_draw_battle_end(renderer, world, battle, game);
    747 		if (context->menu == PH_MENU_MAIN)
    748 			ph_draw_options(renderer, context->options);
    749 		ph_draw_debug_stats(renderer, world, debug);
    750 		ph_render_present(renderer);
    751 		return;
    752 	}
    753 
    754 	ph_render_color(renderer, (PhColor){ PH_CLEAR_COLOR });
    755 	ph_render_clear(renderer);
    756 	ph_camera_follow_player(&camera, world);
    757 	ph_render_world(renderer, world, &camera);
    758 	if (debug && debug->enabled) ph_render_world_debug(renderer, world, &camera);
    759 	if (context->menu == PH_MENU_CHARACTER) {
    760 		ph_draw_character_sheet(renderer, world, labels);
    761 	} else if (context->menu == PH_MENU_INVENTORY) {
    762 		ph_draw_inventory(renderer, world, context->inventory, labels);
    763 	} else if (context->menu == PH_MENU_SPELLBOOK) {
    764 		ph_draw_spellbook(renderer, world, context->spells, context->spellbook, labels);
    765 	} else if (context->menu == PH_MENU_SHOP) {
    766 		ph_draw_shop(renderer, world, context->spells, context->shop, labels);
    767 	} else if (context->menu == PH_MENU_MAIN) {
    768 		ph_draw_options(renderer, context->options);
    769 	} else if (context->menu == PH_MENU_START) {
    770 		ph_draw_start(renderer, context->start);
    771 	} else if (world->notice.seconds > 0.0f && world->notice.text[0] != '\0') {
    772 		PhRect notice_bg = {
    773 			.x = 8.0f,
    774 			.y = (float)(PH_VIEW_H - 28),
    775 			.w = (float)(PH_VIEW_W - 16),
    776 			.h = 20.0f,
    777 		};
    778 
    779 		ph_render_color(renderer, (PhColor){ PH_NOTICE_COLOR });
    780 		ph_render_fill(renderer, notice_bg);
    781 		ph_render_color(renderer, (PhColor){ PH_NOTICE_TEXT_COLOR });
    782 		ph_render_text(renderer, 14.0f, (float)(PH_VIEW_H - 22),
    783 			world->notice.text);
    784 	}
    785 	if (game == PH_GAME_TRANSITION) {
    786 		float progress = 1.0f - battle->timer / PH_BATTLE_TRANSITION_SECONDS;
    787 		float height = progress * PH_VIEW_H * 0.5f;
    788 		PhRect top = { 0.0f, 0.0f, PH_VIEW_W, height };
    789 		PhRect bottom = { 0.0f, PH_VIEW_H - height, PH_VIEW_W, height };
    790 
    791 		ph_render_color(renderer, (PhColor){ 0, 0, 0, 255 });
    792 		ph_render_fill(renderer, top);
    793 		ph_render_fill(renderer, bottom);
    794 	}
    795 	ph_draw_debug_stats(renderer, world, debug);
    796 	ph_render_present(renderer);
    797 }