/confec.cpp (d192eb66561ed3a920cfeda8a62247e50060024e) (194779 bytes) (mode 100644) (type blob)

#include <stdio.h>
// fopen override declaration.
FILE *FileOpen( const char* filename, const char* modes );

#include <cstring>

// Trick cute_sound.h into thinking stb_vorbis.c is included here.
// This avoids a stupid conflict with a macro called "R" in Bullet.
#define STB_VORBIS_INCLUDE_STB_VORBIS_H

// Implementations defined in libs.cpp.
#include <stb_image.h>
#include <stb_truetype.h>
#include <cute_sound.h>
//#include <ne.h>
#include <proc.h>
#include <tm_json.h>
#include <ini.h>
#include <micropather.h>

// Platform-specific headers.
#ifdef __EMSCRIPTEN__
	#include <emscripten/emscripten.h>
	#include <emscripten/websocket.h>
#else
	#ifdef __STEAM__
		#include <steam/steam_api.h>
	#endif
	/*
	#include <Poco/Exception.h>
	#include <Poco/Timespan.h>
	#include <Poco/Net/HTTPRequest.h>
	#include <Poco/Net/HTTPResponse.h>
	#include <Poco/Net/HTTPClientSession.h>
	#include <Poco/Net/HTTPSClientSession.h>
	#include <Poco/Net/WebSocket.h>
	*/
	// Asio and Beast stuff takes a long time to compile.
	#define BOOST_ASIO_SEPARATE_COMPILATION
	#define BOOST_BEAST_SEPARATE_COMPILATION
	#include <boost/beast/core.hpp>
	#include <boost/beast/ssl.hpp>
	#include <boost/beast/websocket.hpp>
	#include <boost/beast/websocket/ssl.hpp>
	#include <boost/asio/connect.hpp>
	#include <boost/asio/ip/tcp.hpp>
	#include <boost/asio/ssl/stream.hpp>
	#include <boost/asio/ssl/rfc2818_verification.hpp>
	#include <mutex>
	#include <thread>
	#include <regex>
#endif

// OpenGL wrapper.
#if defined(__EMSCRIPTEN__)
	#include <fg2/fg2.h>
	#include <fg2/linalg.h>
	#define fgl fg2
#else
	#include <SDL2/SDL.h>
	#include <fg3/fg3.h>
	#include <fg3/linalg.h>
	#define fgl fg3
#endif

#ifndef __LIGHT__
	// The 3D dungeon engine is not included in the Confectioner Engine source release due to
	// an ongoing rewrite. Code to fetch and build the Bullet physics engine has been removed
	// from the makefile for the same reason. The 3D stuff is now a separate project.
	// Code in this file (the #ifndef __LIGHT__ parts) preserved for posterity.

	// Perlin noise.
	#define STB_PERLIN_IMPLEMENTATION
	#include <stb_perlin.h>
	// Bullet physics.
	// Use doubles to match the version of Bullet produced by
	// build_cmake_pybullet_double.sh
	#define BT_USE_DOUBLE_PRECISION
	#include <btBulletDynamicsCommon.h>
	#include <BulletCollision/Gimpact/btGImpactCollisionAlgorithm.h>
	#include <btrapVertexBuffer.h>
	// These 2 headers are not included in this source release.
	// glTF loader.
	#include <fgltf.h>
	// 3D dungeon engine.
	#include <fdungeon.h>
#endif

// General-purpose game headers.
#include <en_to_zh.h>
#define DIALOGUE_FOPEN FileOpen
#include <dialogue.h>
#include <fseq.h>
#define FWORLD_FOPEN FileOpen
#include <fworld.h>

// Headers specific to Confectioner Engine.
#include "include/flora.h"
#include "include/civilian.h"
#include "include/fighter.h"
//#include "include/wolf.h"

// For _mkdir/mkdir.
#ifdef _MSC_VER
	#include <direct.h>
#else
	#include <sys/stat.h>
#endif

#include <locale.h>

#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <ctime>

#include <algorithm>
#include <exception>
#include <filesystem>
#include <limits>
#include <map>
#include <random>
#include <string>
#include <vector>

#define DBL_INF std::numeric_limits<double>::infinity()

double lerp( double a, double b, double f ){
	return f * ( b - a ) + a;
}

double ease( double a, double b, double f ){
	f *= 2.0;
	if( f < 1.0 ){
		f *= f * 0.5;
	}else{
		f -= 2.0;
		f = f * f * -0.5 + 1.0;
	}
	return f * ( b - a ) + a;
}

fgl::Texture loadImage( std::string fileName, bool mipmap, bool filter ){
	int imgWidth = 0, imgHeight = 0, imgChannels = 0;
	unsigned char *imgData = nullptr;
	FILE *file = FileOpen( fileName.c_str(), "rb" );
	if( file ){
		imgData = stbi_load_from_file( file, &imgWidth, &imgHeight, &imgChannels, 0 );
		fclose( file );
	}
	fgl::Texture imgTexture = fgl::loadTexture( imgData, imgWidth, imgHeight, imgChannels, mipmap, filter );
	stbi_image_free( imgData );
	return imgTexture;
}

void drawImage( fgl::Texture &tex, float posX, float posY, float angle, float scaleX, float scaleY ){
	double screenWidth = fgl::getDisplayWidth(), screenHeight = fgl::getDisplayHeight();
	double textureWidth = tex.width, textureHeight = tex.height;
	fgl::setTexture( tex, 0 );
	fgl::drawMesh(
		fgl::planeMesh,
		linalg::mul(
			linalg::translation_matrix( linalg::vec<double,3>(
				( textureWidth * 0.5 * scaleX + posX ) / screenHeight * 2.0 - screenWidth / screenHeight,
				( textureHeight * 0.5 * scaleY + posY ) / screenHeight * -2.0 + 1.0,
				0.0
			) ),
			linalg::mul(
				linalg::rotation_matrix( fgl::eulerToQuat( 0.0, 0.0, angle ) ),
				linalg::scaling_matrix( linalg::vec<double,3>(
					textureWidth / screenHeight * scaleX * 2.0,
					textureHeight / screenHeight * scaleY * 2.0,
					1.0
				) )
			)
		),
		linalg::identity,
		linalg::scaling_matrix( linalg::vec<double,3>( screenHeight / screenWidth, 1.0, 1.0 ) )
	);
}

dialogue::Talk convo;

fgl::Font font_vn, font_mono, font_inventory;

fgl::Texture shadeBox, button_n, button_p;

// VN variables.
long long health = 0, day = 0, money = 0, multiplayer = 0, mainmenu = 0;

// Animation variables.
bool animation_loop = false;
int animation_layer = 0;
double animation_fps = 0.0;

// Image subsystem variables.
int bgIndex = -1, fgIndex = -1, choiceIndex = -1;

struct Image {
	std::string imgName;
	fgl::Texture imgTexture;
};

std::vector<Image> backgrounds;
std::vector<Image> foregrounds;

// Get the index to the specified background image, or -1 if failure.
int findBackground( std::string imgName ){
	if( imgName.length() > 0 ){
		for( int i = 0; i < (int)backgrounds.size(); i++ ){
			if( backgrounds[i].imgName == imgName ){
				return i;
			}
		}
	}
	return -1;
}

// Get the index to the specified foreground image, or -1 if failure.
int findForeground( std::string imgName ){
	if( imgName.length() > 0 ){
		for( int i = 0; i < (int)foregrounds.size(); i++ ){
			if( foregrounds[i].imgName == imgName ){
				return i;
			}
		}
	}
	return -1;
}

// Remove all loaded backgrounds and foregrounds from video memory.
// These images are loaded from disk again the next time they are used.
void freeImages(){
	for( auto &img : backgrounds ){
		fgl::freeTexture( img.imgTexture );
	}
	backgrounds = {};
	for( auto &img : foregrounds ){
		fgl::freeTexture( img.imgTexture );
	}
	foregrounds = {};
}

// Global sound volume.
float global_volume = 1.0f;

struct SoundSpec {
	cs_loaded_sound_t *loaded_ptr;
	float volume;
	int reverbEchoes;
	float reverbVolumeFactor;
	float reverbDelay;
	float reverbDelayFactor;
};

std::string sound_spec_name;

std::map<std::string,SoundSpec> sound_specs;

std::map<std::string,cs_loaded_sound_t> sound_loaded;

cs_context_t *csctx;

// Loads a sound and associates it with sound_name.
void csLoadSound(
		std::string sound_name,
		std::string file_name,
		float volume,
		int reverbEchoes,
		float reverbVolumeFactor,
		float reverbDelay,
		float reverbDelayFactor ){
	// Load the sound file if it does not exist in sound_loaded.
	if( sound_loaded.find( file_name ) == sound_loaded.end() ){
		void *memory = nullptr;
		size_t length = 0;
		FILE *file = FileOpen( file_name.c_str(), "rb" );
		if( file ){
			// Adapted from cs_read_file_to_memory.
			fseek( file, 0, SEEK_END );
			length = ftell( file );
			fseek( file, 0, SEEK_SET );
			memory = malloc( length );
			(void)(fread( memory, length, 1, file ) + 1); // Discard return value
			fclose( file );
		}
		// Process the in-memory file into a playable audio clip.
		cs_loaded_sound_t sound = { 0, 0, 0, 0, { nullptr, nullptr } };
		if( file_name.length() >= 4
			&& file_name.substr( file_name.length() - 4 ) == ".ogg" ){
			cs_read_mem_ogg( memory, length, &sound );
		}else{
			cs_read_mem_wav( memory, length, &sound );
		}
		free( memory );
		sound_loaded[file_name] = sound;
	}
	// Cancel loading if the sound file failed to load. The unplayable
	// sound stays in sound_loaded and prevents future loading attempts.
	if( sound_loaded[file_name].sample_count < 1 ){
		fprintf( stderr, "Failed to load %s\n", file_name.c_str() );
		return;
	}
	sound_specs[sound_name] = {
		&sound_loaded[file_name],
		volume,
		reverbEchoes,
		reverbVolumeFactor,
		reverbDelay,
		reverbDelayFactor
	};
}

void csPlaySound( std::string sound_name, bool loop ){
	// Find the cs_loaded_sound_t.
	auto it = sound_specs.find( sound_name );
	if( it == sound_specs.end() )
		return;
	SoundSpec *spec = &it->second;
	// Skip null sounds.
	if( spec->loaded_ptr == nullptr )
		return;
	cs_play_sound_def_t def = cs_make_def( spec->loaded_ptr );
	// https://web.archive.org/web/20160414075412/http://www.soundonsound.com/sos/Oct01/articles/advancedreverb1.asp
	// https://stackoverflow.com/questions/5318989/reverb-algorithm
	float
		volume = spec->volume * global_volume,
		delta = 0.0f,
		deltaFactor = 1.0f;
	for( int i = 0; i < spec->reverbEchoes + 1; i++ ){
		def.volume_left = volume;
		def.volume_right = volume;
		def.delay = delta;
		def.looped = loop;
		cs_play_sound( csctx, def );
		volume *= spec->reverbVolumeFactor;
		delta += spec->reverbDelay * deltaFactor;
		deltaFactor *= spec->reverbDelayFactor;
	}
}

bool csSoundIsPlaying( std::string sound_name ){
	// Find the cs_loaded_sound_t.
	auto it = sound_specs.find( sound_name );
	if( it == sound_specs.end() )
		return false;
	// Skip null sounds.
	if( it->second.loaded_ptr == nullptr )
		return false;
	// Determine if the sound is playing.
	bool is_playing = false;
	cs_lock( csctx );
	cs_playing_sound_t *sound = cs_get_playing( csctx );
	while( sound ){
		if( sound->loaded_sound == it->second.loaded_ptr ){
			is_playing = true;
			break;
		}
		sound = sound->next;
	}
	cs_unlock( csctx );
	return is_playing;
}

void csStopLoadedSound( cs_loaded_sound_t *loaded_sound ){
	// Return if loaded_sound is nil or not playing.
	if( !loaded_sound || loaded_sound->playing_count < 1 )
		return;
	// Stop all instances of the sound.
	cs_lock( csctx );
	cs_playing_sound_t *sound = cs_get_playing( csctx );
	while( sound ){
		// Let cs_mix() remove the sound.
		if( sound->loaded_sound == loaded_sound )
			sound->active = 0;
		sound = sound->next;
	}
	cs_unlock( csctx );
}

void csSetLoadedSoundVolume( cs_loaded_sound_t *loaded_sound, float v ){
	// Return if loaded_sound is nil or not playing.
	if( !loaded_sound || loaded_sound->playing_count < 1 )
		return;
	// Apply the global volume.
	v *= global_volume;
	// Lower the volume of all instances.
	cs_lock( csctx );
	cs_playing_sound_t *sound = cs_get_playing( csctx );
	while( sound ){
		if( sound->loaded_sound == loaded_sound ){
			sound->volume0 = v;
			sound->volume1 = v;
		}
		sound = sound->next;
	}
	cs_unlock( csctx );
}

void csSwitchSound( cs_loaded_sound_t *from, cs_loaded_sound_t *to ){
	if( !from || !to || to->playing_count > 0 )
		return;
	cs_lock( csctx );
	cs_playing_sound_t *sound = cs_get_playing( csctx );
	while( sound ){
		if( sound->loaded_sound == from ){
			sound->loaded_sound = to;
			sound->sample_index %= to->sample_count;
			from->playing_count--;
			to->playing_count++;
		}
		sound = sound->next;
	}
	cs_unlock( csctx );
}

typedef fworld::mesa<std::string,std::string> IniSection;

struct IniSettings {
	IniSection global;
	fworld::mesa<std::string,IniSection> sections;
} settings;

// Default value for empty settings fields.
std::string settings_none = "none";

// Category to display in the current settings menu.
std::string settings_category = "";

struct Recipe {
	bool unlocked;
};

std::map<std::string,Recipe> recipes;

struct Popup {
	fgl::Texture *texture;
	std::string s1;
	std::string s2;
	double countdown;
	bool visible;
};

std::vector<Popup> popups;

double popup_countdown = 5.0, popup_scale_speed = 4.0;

struct SpecialItem {
	fgl::Texture icon;
	std::string name;
	int id;
	int count;
};

int32_t special_item_result = 0;

// App variable definitions.
std::string app_version = "v0.5.0";
#ifdef __DEMO__
	app_version += " demo";
	std::string app_name = "Confectioner Engine Demo";
	std::uint32_t app_steam_id = 1319170;
	std::vector<int> inventory_service_generators = {};
	std::vector<SpecialItem> special_items;
#else
	std::string app_name = "Confectioner Engine";
	std::uint32_t app_steam_id = 1181900;
	std::vector<int> inventory_service_generators = { 20, 10 };
	#ifdef __STEAM__
		std::vector<SpecialItem> special_items;
	#else
		// Give DRM-free users essential special items.
		std::vector<SpecialItem> special_items = {
			{ fgl::newTexture, "Cake Pan", 100, 1 }
		};
	#endif
#endif

// Default to English.
std::string language = "en";

// Whitelist these languages. Generally, every game should support these.
std::vector<std::string> language_whitelist = { "en", "zh" };

// Use these mods. The "game" folder is layered on top of the base data.
std::vector<std::string> mod_paths = { "game" };

bool recipes_display = false, trading_display = false,
	sequencer_display = false, cake_display = false,
	settings_display = false, character_select_display = false;

std::string character_selected = "", character_next_screen = "",
	sequencer_name = "";

fworld::Entity *trading_entity;

fgl::Texture tex_circle_grey, tex_circle_orange, tex_cursor,
	tex_net_notification, tex_notification, tex_recipe_overlay,
	tex_boxes, tex_box_select, tex_character_select;

fgl::Framebuffer framebuffer;

double circleX = DBL_INF, circleY = DBL_INF;

int bumpIndex = -1;

std::vector<bool> item_selected, partner_item_selected;
bool somethingSelected = false;

int landscapeScreenHeight2D = 384;
int landscapeScreenHeight3D = 439; // Looks good at 768p.
int portraitScreenHeight2D = 480;
int portraitScreenHeight3D = 549; // Supposed to look good at 960p.

// Adjusted at runtime.
int simScreenHeight2D = landscapeScreenHeight2D;
int simScreenHeight3D = landscapeScreenHeight3D;

fgl::Color ambient_colors[] = {
	{ 0.4f,  0.5f,  0.8f, 1.0f }, // Night.
	{ 0.6f,  0.5f,  0.8f, 1.0f }, // Morning twilight.
	{ 1.05f, 0.96f, 0.9f, 1.0f }, // Morning.
	{ 1.15f, 1.06f, 1.0f, 1.0f }, // Day.
	{ 1.0f,  0.7f,  0.9f, 1.0f }, // Sunset.
	{ 0.6f,  0.5f,  0.8f, 1.0f }  // Evening twilight.
};

fgl::Color ambient_light = { 1.0f, 1.0f, 1.0f, 1.0f };

GLfloat
	bloomSizeX     = 3.43f,
	bloomSizeY     = 2.74f,
	bloomThreshold = 0.56f,
	bloomAmount    = 0.25f;

fgl::Pipeline bloomPipeline;

std::string fadeTo = "";

// Keeps track of the server's world time, translates to time of day.
double world_time = 0.0;

// These numbers should feel balanced for the game.
int max_health = 100, threshold_health = 26, inventory_slots = 8;

// Time of day from 0-1. Assumes noon by default.
double default_time = 0.5;
double player_time = default_time;

// These variables control day/night transitions.
double
	day_duration = 18.0 * 60.0, // 18 minutes.
	sleep_duration = 6.0 * 60.0, // 6 minutes.
	campfire_duration = 3.0 * 60.0, // 3 minutes.
	plant_grow_period = 9.0 * 60.0; // 9 minutes.

// Possibly switch to sprite batching so 50+ FPS can be maintained everywhere.
double minFps = 20.0;

// The analog dead zone and speed zone for the joystick.
float dead_zone = 0.1f, speed_zone = 0.9f;

// For movement, actions, and menus.
float stickX = 0.0f, stickY = 0.0f, moveX = 0.0f, moveY = 0.0f;
int moveXInt = 0, moveYInt = 0, moveXDown = 0, moveYDown = 0;
bool
	actionButton = false, actionButtonDown = false,
	recipesButton = false, recipesButtonDown = false,
	meleeButton = false, meleeButtonDown = false,
	entityButton = false, entityButtonDown = false,
	jumpButton = false, jumpButtonDown = false,
	sleepButton = false, sleepButtonDown = false,
	pauseButton = false, pauseButtonDown = false,
	mouseLeft = false, mouseDownLeft = false,
	mouseRight = false, mouseDownRight = false;

#ifndef __LIGHT__
	fdungeon::Dungeon dungeon;
#endif

fworld::World world;

// Remember the last zone for pre-save cleanup.
int last_zone = -1;

// For plant growth, AI, and procedural generation.
std::mt19937_64 mt;
std::uniform_int_distribution<uint32_t> seed_dist;
uint32_t zone_seed = 0;

std::vector<flora::Plant> plants;
std::vector<civilian::Civilian> civilians;
std::vector<fighter::Fighter> fighters;
//std::vector<wolf::Wolf> wolves;

// Number of save slots.
size_t save_slots = 3;

// List of fworld player names.
std::vector<std::string> save_names = {};

// The folder containing data for the current saved game.
std::string save_path = "";

// The folder containing the user data.
std::string user_data_path = "";

// The folder containing the base game data.
std::string data_path = "base";

// The file containing a list of the user's currently unlocked recipes.
std::string user_recipes = "recipes.txt";

// Used by the text input prompt.
std::string *inputTarget = nullptr;
std::string inputPrompt = "";

// Enabled if logged into Steam.
bool use_steam = false;

// Enabled if logged into Steam and Steam Cloud is enabled.
bool use_steam_cloud = false;

// Enabled if Steam China is used.
bool is_steam_china = false;

// Enabled if mouse controls are to be used.
bool show_cursor = true;

// Enabled if the pause menu is activated.
bool paused = false;

// Enabled if the player character is sleeping.
bool sleep_mode = false;

// The name of the target entity to be displayed on the screen.
std::string target_name = "";

// Needs to be a global due to VN global state. :/
std::string vn_callback_file = "";

// Rain is produced when enabled.
bool raining = false;

// Maximum number of raindrop particles.
size_t max_raindrops = 1000;

struct Raindrop {
	bool visible;
	double x, y, z, vel_x, vel_y, vel_z;
};

std::vector<Raindrop> raindrops;

size_t max_gibs = 500;

double gib_floor = -7.6;

struct Gib {
	bool visible;
	double x, y, z, vel_x, vel_y, vel_z;
};

std::vector<Gib> gibs, gibs_on_floor;

// Variables for calculating framerate.
double timeCount = 0.0;
int frames = 0, showFrames = 0;

// WebSocket globals.
// Not thread-safe but safe to use if you know what you're doing.
std::string ws_chat_message;
std::string ws_out;
std::vector<fworld::Entity> ws_entities;
void (*ws_callback)(std::string);

// A record of the last known state of a network entity.
struct PeerState {
	double last_x;
	double last_y;
	int task;
};

std::map<unsigned long long,PeerState> ws_peer_states;

#ifdef __EMSCRIPTEN__
	EMSCRIPTEN_WEBSOCKET_T ws_socket = 0;
	EM_BOOL wsOnClose( int eventType, const EmscriptenWebSocketCloseEvent *e, void *userData );
	EM_BOOL wsOnError( int eventType, const EmscriptenWebSocketErrorEvent *e, void *userData );
	EM_BOOL wsOnMessage( int eventType, const EmscriptenWebSocketMessageEvent *e, void *userData );
#else
	/*
	// Client session may be HTTP or the HTTPS derived class.
	Poco::Net::HTTPClientSession *ws_client_session = nullptr;
	Poco::Net::WebSocket *ws_socket = nullptr;
	bool ws_socket_active = false;
	const int ws_buffer_size = 65536;
	char ws_buffer[ws_buffer_size];
	void wsHandleException( Poco::Exception &e );
	*/
	namespace ws_net = boost::asio;
	namespace ws_websocket = boost::beast::websocket;
	// The io_context is required for all network I/O
	ws_net::io_context *ws_ioc;
	// The SSL context holds SSL/TLS certificates
	//ws_net::ssl::context ws_ctx{ ws_net::ssl::context::tlsv12_client };
	ws_net::ssl::context *ws_ctx;
	// These objects perform I/O
	ws_websocket::stream<boost::beast::ssl_stream<ws_net::ip::tcp::socket>> *ws_ssl;
	ws_websocket::stream<ws_net::ip::tcp::socket> *ws_plain;
	ws_net::ip::tcp::socket *ws_tcp_sock;
	// This buffer holds incoming messages.
	boost::beast::flat_buffer ws_buffer;
	// This queue holds outgoing messages.
	std::vector<std::string> ws_write_queue;

	// TODO: Put this in the settings or something.
	std::string ws_win32_pem = "openjdk.pem";

	bool ws_ready = true, ws_socket_active = false, ws_use_ssl = false;
	std::mutex ws_mutex;
	std::thread ws_thread;

	std::smatch wsParseAddress( const std::string &uri );
	void wsHandleRead( boost::system::error_code ec, size_t n );
	void wsHandleWrite( boost::system::error_code ec, size_t n );
#endif

std::vector<std::string> chat_buffer;
size_t chat_buffer_capacity = 6;

void wsConnect( const std::string &uri, void (*f)(std::string) );
void wsDisconnect();
bool wsSendString( const std::string &str );

void vnDataUpload();
void vnDataDownload( const std::string &data_path );
void vnDraw( double sw, double sh );

double SEval( std::string expression );

void LoadSettings( std::string file_name );
void SaveSettings( std::string file_name );

void InitSettings();

void InitCloud();

void DeleteAllUserFiles();

void HitCallback( fworld::Entity *ent_a, fworld::Entity *ent_b, int damage );

void Notify( fgl::Texture *texture, std::string s1, std::string s2 );

void AddRecipe( std::string name );

void Achieve( std::string id );

void AddEntity( fworld::Entity &ent, double x, double y, int idx = -1, bool recalc = true );

int GetEntityAt( double x, double y );

int GetItemHealth( fworld::Entity &entity, fworld::Item &item );

int GetItemPayPrice( fworld::Entity &entity, fworld::Item &item );

void Eat( fworld::Entity &entity, fworld::Item &item );

void KillEntity( fworld::Entity &entity );

void RegenerateZone( int zone, uint32_t seed );

void SyncSaveData( size_t slot );

void SoundSpecLoad( std::string sounds_path, std::string spec_path );

void SaveGame( size_t slot );

void LoadingScreen( std::string text, fgl::Font &font, float scale );

void Warp( std::string param );

void LoadMap( std::string file_path, bool autosave );

fworld::Entity GetMapSpawn( std::string dataPath, std::string filePath );

void PauseMenu( std::string fileName );

void OpenSettings( std::string category );

void OpenCharacterSelect( std::string next_screen );

void TextInputPrompt( std::string *target, std::string prompt = "" );

std::string StringSanitize( std::string str );

int GetButtonState( std::string *button );

bool WorldInteract( int ent_index );

int GetTradingTotal();

void BeginTrading();

void FinishTrading();

void BeginSequencer( std::string param );

void FinishSequencer();

void BeginCake();

void FinishCake();

void Tick();

void TurboSleep();

void UpdateRichPresence();

void UpdateSpecialItems();

void PollSpecialItems();

void UpdateInventoryService();

void ModUpload( std::string title );

void Render();

void MakeGibs( double x, double y, linalg::vec<double,3> vel, int n );

void SimulateParticles( double d );

void DrawFloorParticles();

void DrawParticles();

void DrawSettings();

void DrawCharacterSelect();

void DrawPopups( double d );

void DrawInventory(
		fworld::Inventory &inventory,
		float pos_x,
		float pos_y,
		float scale,
		std::vector<bool> &selections );

void DrawSpecialItems(
		float pos_x,
		float pos_y,
		float scale );

void GameLoop( double d );

void FirstPersonLoop( double d );

bool PlayerUnderwater();

void Quit();

#ifdef __STEAM__
	class SteamTextHandler {
		// Steam Big Picture text input callback.
		STEAM_CALLBACK( SteamTextHandler, OnTextInputDismissed, GamepadTextInputDismissed_t );
	};
	void SteamTextHandler::OnTextInputDismissed( GamepadTextInputDismissed_t *pCallback ){
		if( inputTarget && pCallback->m_bSubmitted ){
			char text[256];
			SteamUtils()->GetEnteredGamepadTextInput( text, sizeof(text) );
			// Update the text in the game.
			fgl::textInputString = std::string( text );
		}
	}
	SteamTextHandler *steam_text_handler;

	class SteamModCreateHandler {
		// Steam Workshop mod create callback.
		STEAM_CALLBACK( SteamModCreateHandler, OnModCreate, CreateItemResult_t );
	};
	void SteamModCreateHandler::OnModCreate( CreateItemResult_t *pCallback ){
		uint64_t file_id = pCallback->m_nPublishedFileId;
		std::string url =
			"steam://url/CommunityFilePage/" + std::to_string( file_id );
		if( pCallback->m_bUserNeedsToAcceptWorkshopLegalAgreement )
			SteamFriends()->ActivateGameOverlayToWebPage( url.c_str() );
		UGCUpdateHandle_t update =
			SteamUGC()->StartItemUpdate( app_steam_id, file_id );
		
		
	}
	SteamModCreateHandler *steam_mod_create_handler;

	class SteamModUploadHandler {
		// Steam Workshop mod upload callback.
		STEAM_CALLBACK( SteamModUploadHandler, OnModUpload, CreateItemResult_t );
	};
	void SteamModUploadHandler::OnModUpload( CreateItemResult_t *pCallback ){
		//SteamUGC()->StartItemUpdate( steam_app_id, file_id );
	}
	SteamModUploadHandler *steam_mod_upload_handler;
#endif

int main( int argc, char* argv[] ){
	// Steam setup.
	#ifdef __STEAM__
		// The app ID for the game.
		if( SteamAPI_RestartAppIfNecessary( app_steam_id ) ){
			return 1;
		}
		// Initialize the Steam API.
		if( SteamAPI_Init() ){
			// Check if the Steam user is logged in.
			if( SteamUser()->BLoggedOn() ){
				use_steam = true;
				// Asynchronously download Steam achievement data.
				if( SteamUserStats() )
					SteamUserStats()->RequestCurrentStats();
				// Determine if Steam Cloud is used.
				if( SteamRemoteStorage()->IsCloudEnabledForAccount()
					&& SteamRemoteStorage()->IsCloudEnabledForApp() )
					use_steam_cloud = true;
				// Determine if Steam China is used.
				if( SteamUtils()->IsSteamChinaLauncher() )
					is_steam_china = true;
				// Determine language.
				std::string game_lang = SteamApps()->GetCurrentGameLanguage();
				if( game_lang == "schinese" || game_lang == "tchinese" )
					language = "zh";
				// Register the text handler.
				steam_text_handler = new SteamTextHandler();
			}else{
				fprintf( stderr, "Error - Steam user does not appear to be logged in (SteamUser()->BLoggedOn() returned false).\n" );
			}
		}else{
			// The extra newline is needed as it is missing from Steam's error output on Linux.
			fprintf( stderr, "\nError - Steam does not appear to be running (SteamAPI_Init() failed).\n" );
		}
	#else
		// Determine language. Reset locale to deal with "unholy things" in GCC.
		std::string loc = setlocale( LC_ALL, "" );
		if( ( loc.length() >= 2 && loc.substr( 0, 2 ) == "zh" )
			|| ( loc.length() >= 7 && loc.substr( 0, 7 ) == "Chinese" ) ){
			language = "zh";
		}
	#endif

	bool windowed_mode = false;

	for( int i = 1; i < argc; i++ ){
		std::string arg( argv[i] );
		if( arg.length() >= 2 && arg[0] == '-' ){
			if( arg.length() >= 3 && arg.substr( 0, 2 ) == "-m" ){
				// Push the mod path to the front of the vector.
				// This gives it a higher priority than other mod paths,
				// such that later -m parameters override earlier -m
				// parameters.
				mod_paths.insert( mod_paths.begin(), arg.substr( 2 ) );
				printf( "Using mod: %s\n", mod_paths[0].c_str() );
			}else if( arg == "--window" ){
				windowed_mode = true;
				printf( "Starting in windowed mode.\n" );
			}else{
				fprintf(
					stderr,
					"Unrecognized argument. Valid arguments are:\n%s\n",
					"-mModPath --window"
				);
			}
		}else{
			// The argument is the game data path.
			data_path = arg;
		}
	}

	// Default to a local path if there is no system user data folder.
	user_data_path = data_path + "/userdata";

	// Make a user data path based on platform and app name.
	#ifdef _WIN32
		// Windows.
		char* localappdata = std::getenv( "LocalAppData" );
		if( localappdata ){
			user_data_path = std::string( localappdata ) + "/" + app_name;
		}
	#elif defined(__APPLE__)
		// MacOS (and iOS?)
		// This does not work with MacOS sandboxing.
		char* home = std::getenv( "HOME" );
		if( home ){
			user_data_path = std::string( home ) + "/Library/Application Support/" + app_name;
		}
	#elif !defined(__EMSCRIPTEN__)
		// Other POSIX (Linux, Android, etc.)
		char* xdgdatahome = std::getenv( "XDG_DATA_HOME" );
		if( xdgdatahome ){
			user_data_path = std::string( xdgdatahome ) + "/" + app_name;
		}else{
			char* home = std::getenv( "HOME" );
			if( home ){
				user_data_path = std::string( home ) + "/.local/share/" + app_name;
			}
		}
	#endif

	// Create the user data folder if it doesn't exist.
	#if defined(_MSC_VER)
		// MSVC.
		_mkdir( user_data_path.c_str() );
	#elif defined(_WIN32)
		// MinGW and other (hopefully POSIX-compatible) non-MSVC
		// compilers.
		mkdir( user_data_path.c_str() );
	#else
		// POSIX compiler on a POSIX system.
		// Same default permissions as set by most file managers.
		// User: read, write, and execute
		// Group: read, write, and execute
		// Others: read and execute
		mkdir( user_data_path.c_str(), 0775 );
	#endif

	fgl::verbose = false;
	fworld::verbose = false;

	//fgl::Display display = fgl::createDisplay( 378, 672, app_name, 0, false );
	//fgl::Display display = fgl::createDisplay( 720, 576, app_name, 0, false );
	//fgl::Display display = fgl::createDisplay( 1280, 720, app_name, 0, false );
	fgl::Display display = windowed_mode
		? fgl::createDisplay( 960, 540, app_name, 0, false )
		: fgl::createDisplay( 0, 0, app_name, 0, false );
	if( !display.success ){
		return 1;
	}

	#ifndef __EMSCRIPTEN__
		fgl::showMouse( false );
	#endif

	// Load only the mono font to display the loading screen quickly.
	font_mono = fgl::loadFont( data_path + "/ui/Confectioner.ttf", 27.6, 2, 2 );
	fgl::cls( { 0.0f, 0.0f, 0.0f, 0.0f } );
	LoadingScreen(
		"...",
		font_mono,
		fgl::getDisplayHeight() / (float)simScreenHeight2D
	);

	// Create default settings.
	InitSettings();

	// Override settings with settings.ini.
	LoadSettings( user_data_path + "/settings.ini" );

	// Set the global volume.
	IniSection &misc = settings.sections.get( "Misc" );
	char* p;
	global_volume =
		std::strtof( misc.get( "global_volume" ).c_str(), &p ) * 0.01f;
	if( *p ) global_volume = 1.0f;

	// Get the user-specified language code.
	std::string lang_override = misc.get( "language_code" );
	// Check the language whitelist and only use valid language codes.
	for( std::string &code : language_whitelist ){
		if( lang_override == code ){
			language = lang_override;
			break;
		}
	}

	// Load the other fonts.
	// Use a higher-resolution texture atlas for non-English.
	int res = ( language == "en" ) ? 1024 : 4096;
	font_vn = fgl::loadFont( data_path + "/ui/" + language + ".ttf", 34.95, 2, 2, true, res, res );
	font_inventory = fgl::loadFont( data_path + "/ui/Confectioner.ttf", 21.0, 1, 1 );

	// Download all user files from the cloud.
	InitCloud();

	// Load special item icons for DRM-free users.
	for( auto &item : special_items ){
		item.icon = world.getTexture(
			data_path,
			"special/" + std::to_string( item.id ) + ".png",
			false
		);
	}

	// 1024 samples keeps audio latency below 45 ms.
	csctx = cs_make_context( nullptr, 44100, 1024, 10, nullptr );

	#ifndef __EMSCRIPTEN__
		cs_spawn_mix_thread( csctx );
		cs_thread_sleep_delay( csctx, 5 );
	#endif

	// Load the initial sound specifications.
	SoundSpecLoad(
		data_path + "/sounds",
		data_path + "/sounds/sounds.json"
	);

	// Set up graphical effects.
	std::string frag_path = data_path + "/glsl/postfx.frag";
	FILE* file = FileOpen( frag_path.c_str(), "rb" );
	if( !file ){
		fprintf( stderr, "Failed to open %s\n", frag_path.c_str() );
		return 1;
	}
	std::string text = "";
	char buf[4096];
	while( size_t len = fread( buf, 1, sizeof( buf ), file ) ){
		text += std::string( buf, len );
	}
	fclose( file );
	bloomPipeline = fgl::loadPipeline( fgl::unlitVert, text.c_str(), { "u_texture" } );
	framebuffer = fgl::createFramebuffer( fgl::getDisplayWidth(), fgl::getDisplayHeight() );
	fgl::setPipeline( fgl::colorModPipeline );

	GLubyte shade[] = { 0x02, 0x02, 0x10, 0x7F };
	shadeBox = fgl::loadTexture( shade, 1, 1, 4, false, false );

	button_n = loadImage( data_path + "/ui/button_n.png", true, true );
	button_p = loadImage( data_path + "/ui/button_p.png", true, true );

	mt = std::mt19937_64( std::time( nullptr ) );

	// This callback is executed from dialogue scripts.
	convo.callback = [&]( std::string param ) -> double {
		if( param.length() > 0 && param[0] == '(' ){
			// Call the S-expression interpreter.
			return SEval( param );
		}
		if( param == "QUIT" )
			Quit();
		std::string cancel_file = convo.file;
		if( param.length() >= 8 && param.substr( 0, 8 ) == "SETTINGS" ){
			// Set vn_callback_file to a valid file to return to.
			vn_callback_file =
				cancel_file.substr( cancel_file.find_last_of( '/' ) + 1 );
			OpenSettings( param.length() > 9 ? param.substr( 9 ) : "" );
			return 0.0;
		}
		if( param.length() > 16
			&& param.substr( 0, 15 ) == "CHARACTERSELECT" ){
			OpenCharacterSelect( param.substr( 16 ) );
			return 0.0;
		}
		if( param == "INPUTNAME"
			&& world.followEntity >= 0
			&& world.entities.size() > (size_t)world.followEntity ){
			#ifdef __STEAM__
				if( use_steam && fgl::controller ){
					// Default to the persona name when a controller is
					// plugged in.
					world.entities[world.followEntity].name =
						SteamFriends()->GetPersonaName();
				}
			#endif
			TextInputPrompt(
				&world.entities[world.followEntity].name,
				"Name your confectioner"
			);
			return 0.0;
		}
		if( param.length() > 9
			&& param.substr( 0, 8 ) == "GIVEITEM" ){
			std::string item_name = param.substr( 9 );
			if( world.items.find( item_name ) == world.items.end() ){
				// Error. Item not found.
				return -1.0;
			}else if( world.followEntity >= 0
				&& world.entities.size() > (size_t)world.followEntity ){
				// The item and the player character exist.
				auto &player = world.entities[world.followEntity];
				fworld::Inventory inv;
				inv.set( item_name, 1.0 );
				if( world.inventoryCanTakeAll(
						player.inventory, inv, inventory_slots ) ){
					world.transferInventory(
						inv,
						player.inventory,
						inventory_slots
					);
					// Success!
					return 0.0;
				}
			}
			// Error. The item does not fit in the player's inventory.
			return -2.0;
		}
		if( param.length() >= 16
			&& param.substr( 0, 4 ) == "ANIM" ){
			// Half of conservative VRAM = 64 MB = 24 frames at 1280x533
			// Looping.
			bool is_loop;
			if( param.substr( 5, 4 ) == "loop" ){
				is_loop = true;
			}else if( param.substr( 5, 4 ) == "play" ){
				is_loop = false;
			}else{
				return 0.0;
			}
			// Layer.
			bool is_foreground;
			if( param.substr( 10, 2 ) == "fg" ){
				is_foreground = true;
			}else if( param.substr( 10, 2 ) == "bg" ){
				is_foreground = false;
			}else{
				return 0.0;
			}
			size_t space_at = param.find_first_of( ' ', 14 );
			if( space_at == std::string::npos
				|| space_at == param.length() - 1 ){
				return 0.0;
			}
			// Frames per second.
			double f =
				world.safeStold( param.substr( 13, space_at - 13 ) );
			f = std::min( std::max( f, 0.01 ), 30.0 );
			// Directory containing frame sequence.
			std::string dir_name = param.substr( space_at + 1 );
			// data_path should technically be first in the vector, but
			// in this case the order doesn't matter.
			std::vector<std::string> search_paths = mod_paths;
			search_paths.push_back( data_path );
			// Check directories for image files.
			std::map<std::string,bool> frame_files;
			for( std::string &path : search_paths ){
				std::string dir_check =
					path + ( is_foreground ? "/fg/" : "/bg/" ) + dir_name;
				std::filesystem::path fspath{ dir_check };
				try{
					for( const auto &entry : std::filesystem::directory_iterator{ fspath } ){
						if( entry.is_regular_file() ){
							auto ext = entry.path().extension();
							if( ext == ".png" || ext == ".PNG"
								|| ext == ".jpg" || ext == ".JPG"
								|| ext == ".jpeg" || ext == ".JPEG" ){
								frame_files[entry.path().filename()] = true;
							}
						}
					}
				}catch( std::exception &e ){
					// Bad file or directory. Moving on.
				}
			}
			if( frame_files.empty() ) return 0.0;
			// Confirmed for loading the animation. Free VRAM.
			freeImages();
			// Load the frames.
			for( auto &frame : frame_files ){
				std::string img_name = dir_name + "/" + frame.first;
				if( is_foreground ){
					foregrounds.push_back( {
						img_name,
						loadImage( data_path + "/fg/" + img_name, true, true )
					} );
				}else{
					backgrounds.push_back( {
						img_name,
						loadImage( data_path + "/bg/" + img_name, true, true )
					} );
				}
			}
			// Set frame and globals.
			if( is_foreground ){
				convo.screen.fg = foregrounds[0].imgName;
			}else{
				convo.screen.bg = backgrounds[0].imgName;
			}
			animation_loop = is_loop;
			animation_layer = is_foreground ? 2 : 1;
			animation_fps = f;
			return 0.0;
		}
		if( param.length() >= 9
			&& param.substr( 0, 9 ) == "SEQUENCER" ){
			BeginSequencer( param.length() > 10 ? param.substr( 10 ) : "" );
			return 0.0;
		}
		if( param == "FINISHSEQUENCER" ){
			FinishSequencer();
			return 0.0;
		}
		if( param == "TRADE" ){
			BeginTrading();
			return 0.0;
		}
		if( param == "FINISHTRADE" ){
			FinishTrading();
			return 0.0;
		}
		if( param == "CAKE" ){
			BeginCake();
			return 0.0;
		}
		if( param == "FINISHCAKE" ){
			FinishCake();
			return 0.0;
		}
		if( param == "DELETEALL" ){
			DeleteAllUserFiles();
			convo.numbers.clear();
			convo.strings.clear();
			convo.arrays.clear();
			InitSettings();
			recipes = {};
			return 0.0;
		}
		if( param == "DISCONNECT" ){
			if( world.followEntity >= 0
				&& world.entities.size() > (size_t)world.followEntity ){
				// Send a parting message.
				auto name = world.entities[world.followEntity].name;
				wsSendString( "{\"name\":\"" + StringSanitize( name )
					+ "\",\"chat_message\":\"/me left\"}" );
			}
			// Clear the chat buffer.
			chat_buffer.clear();
			// Clear the peer states.
			ws_peer_states.clear();
			// Disconnect from the server.
			wsDisconnect();
			// Reset the main player stats.
			health = 0;
			money = 0;
			return 0.0;
		}
		if( param == "SAVEGAME" ){
			// Open the Save Game menu.
			convo.go( "savegame.json" );
		}else if( param == "LOADGAME" ){
			// Open the Load Game menu.
			convo.go( "loadgame.json" );
			// Redirect map JSON file paths to the user data folder.
			save_path = user_data_path;
			// Construct a safety mechanism to nullify clicks on
			// inactive buttons.
			dialogue::Screen loadgame_screen = {};
			loadgame_screen.id = "loadgame";
			loadgame_screen.exec.push_back(
				{ "menu", ':', 0, "CALLBACK LOADGAME" } );
			convo.setScreen( loadgame_screen );
		}
		if( param == "SAVEGAME" || param == "LOADGAME" ){
			if( convo.file != cancel_file ){
				// Set vn_callback_file to a valid file to return to.
				vn_callback_file =
					cancel_file.substr( cancel_file.find_last_of( '/' ) + 1 );
			}
			// Override the background with black if necessary.
			// This keeps the program from exiting during menu loading.
			// TODO: Investigate if this still applies.
			if( world.map.empty() && convo.screen.bg.empty()
				#ifndef __LIGHT__
					&& !dungeon.ready
				#endif
				){
				convo.screen.bg = "black";
			}
			// Prepend save slot labels with player names.
			size_t i = 0;
			for( auto &s : convo.screen.lines ){
				if( i >= save_slots )
					break;
				if( convo.getVariable( "health" + std::to_string( i + 1 ) ) ){
					s = save_names[i] + " " + s;
				}else{
					// TODO: String definitions file.
					s = "[Empty]";
					if( param == "LOADGAME" )
						convo.screen.ids[i] = "loadgame";
				}
				// The first save slot is always used for autosave.
				if( i == 0 )
					s = "[Auto Save] " + s;
				i++;
			}
			return 0.0;
		}
		// Save the game.
		if( param == "SAVE1" ){
			SaveGame( 1 );
			return 0.0;
		}else if( param == "SAVE2" ){
			SaveGame( 2 );
			return 0.0;
		}else if( param == "SAVE3" ){
			SaveGame( 3 );
			return 0.0;
		}
		// If none of the above commands are used, transport the player.
		Warp( param );
		return 0.0;
	};

	#ifndef __LIGHT__
		// Set a callback function for 3D portals.
		dungeon.portalCallback = Warp;
	#endif

	// Set a callback function for 2D portals.
	world.portalCallback = Warp;

	// Initialize baseline map variables.
	LoadMap( "", false );

	vnDataUpload();
	// Use English as the backup dialogue language.
	convo.fallbackDir = data_path + "/dialogue_en";
	convo.go( data_path + "/dialogue_" + language + "/init.json" );
	vnDataDownload( data_path );

	// Sync save metadata.
	SyncSaveData( 0 );

	// Load the item definitions.
	world.loadItems( data_path + "/items.json" );

	// Load the list of currently unlocked recipes.
	file = fopen( ( user_data_path + "/" + user_recipes ).c_str(), "rb" );
	if( file ){
		char line[4096];
		while( fgets( line, sizeof( line ), file ) ){
			size_t len = strlen( line );
			// Strip away new line characters.
			for( int i = 0; i < 2; i++ ){
				if( len > 0
					&& ( line[len - 1] == '\r' || line[len - 1] == '\n' ) ){
					len--;
				}
			}
			if( len > 0 ){
				recipes[std::string( line, len )] = { true };
			}
		}
		fclose( file );
	}

	tex_circle_grey      = loadImage( data_path + "/circle-grey.png",         false, false );
	tex_circle_orange    = loadImage( data_path + "/circle-orange.png",       false, false );
	tex_cursor           = loadImage( data_path + "/ui/cursor.bmp",           false, false );
	tex_net_notification = loadImage( data_path + "/ui/net_notification.png", true,  false );
	tex_notification     = loadImage( data_path + "/ui/notification.png",     true,  false );
	tex_recipe_overlay   = loadImage( data_path + "/ui/recipe_overlay.png",   false, false );
	tex_boxes            = loadImage( data_path + "/ui/boxes.png",            false, false );
	tex_box_select       = loadImage( data_path + "/ui/box_select.png",       false, false );
	tex_character_select = loadImage( data_path + "/ui/character_select.png", false, false );

	#if defined(SDL_MAJOR_VERSION) && !defined(__EMSCRIPTEN__)
		// Use a custom hardware cursor.
		std::string cur_path = data_path + "/ui/cursor.bmp";
		SDL_Surface *surface = SDL_LoadBMP( cur_path.c_str() );
		if( !surface ) fprintf( stderr, "Failed to load cursor bitmap.\n" );
		SDL_Cursor *cursor = SDL_CreateColorCursor( surface, 0, 0 );
		if( !cursor ) fprintf( stderr, "Failed to create color cursor.\n" );
		SDL_SetCursor( cursor );
	#endif

	glDisable( GL_DEPTH_TEST );

	// Don't show the cursor initially if a controller is plugged in.
	if( fgl::controller ){
		show_cursor = false;
		choiceIndex = 0;
	}

	#ifdef __EMSCRIPTEN__
		emscripten_set_main_loop( (em_callback_func)Render, 0, 1 );
	#else
		while( true ){
			Render();
		}
	#endif

	return 0;
}

// fopen replacement that attempts to open the file from multiple locations.
FILE *FileOpen( const char* filename, const char* modes ){
	// Check if filename starts with the base data path.
	std::string str_file = filename, str_data = data_path + "/";
	if( str_file.length() > str_data.length()
		&& str_file.substr( 0, str_data.length() ) == str_data ){
		// Iterate through mod paths.
		std::string suffix = str_file.substr( str_data.length() );
		for( std::string &path : mod_paths ){
			// Return the pointer to the first matching file.
			str_file = path + "/" + suffix;
			FILE *result = fopen( str_file.c_str(), modes );
			if( result ) return result;
		}
	}
	// Fall back to open the path specified.
	return fopen( filename, modes );
}

#ifdef __EMSCRIPTEN__

void wsConnect( const std::string &uri, void (*f)(std::string) ){
	if( !emscripten_websocket_is_supported() ){
		Notify(
			&tex_net_notification,
			"Network Error",
			"WebSocket unsupported"
		);
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
		return;
	}
	EmscriptenWebSocketCreateAttributes attr;
	emscripten_websocket_init_create_attributes( &attr );
	attr.url = uri.c_str();
	// Connect to the server.
	ws_socket = emscripten_websocket_new( &attr );
	if( ws_socket <= 0 ){
		Notify(
			&tex_net_notification,
			"Network Error",
			"WebSocket creation failed"
		);
		ws_socket = 0;
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
		return;
	}
	emscripten_websocket_set_onclose_callback( ws_socket, nullptr, wsOnClose );
	emscripten_websocket_set_onerror_callback( ws_socket, nullptr, wsOnError );
	emscripten_websocket_set_onmessage_callback( ws_socket, (void*)f, wsOnMessage );
}

EM_BOOL wsOnClose( int eventType, const EmscriptenWebSocketCloseEvent *e, void *userData ){
	(void)eventType;
	(void)e;
	(void)userData;
	if( ws_socket ){
		// Clean up the object.
		emscripten_websocket_delete( ws_socket );
		ws_socket = 0;
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
	}
	return 0;
}

EM_BOOL wsOnError( int eventType, const EmscriptenWebSocketErrorEvent *e, void *userData ){
	(void)e;
	(void)userData;
	Notify(
		&tex_net_notification,
		"Network Error",
		"Event type: " + std::to_string( eventType )
	);
	emscripten_websocket_close( ws_socket, 0, nullptr );
	// Clean up the object.
	emscripten_websocket_delete( ws_socket );
	ws_socket = 0;
	// Return to the main menu.
	settings_display = false;
	inputTarget = nullptr;
	convo.go( "init.json" );
	return 0;
}

EM_BOOL wsOnMessage( int eventType, const EmscriptenWebSocketMessageEvent *e, void *userData ){
	(void)eventType;
	if( ws_socket ){
		auto f = (void (*)(std::string))userData;
		f( e->isText ? std::string( (char*)e->data ) : std::string( (char*)e->data, e->numBytes ) );
	}
	return 0;
}

void wsDisconnect(){
	if( ws_socket ){
		emscripten_websocket_close( ws_socket, 0, nullptr );
		// Clean up the object.
		emscripten_websocket_delete( ws_socket );
		ws_socket = 0;
	}
	multiplayer = false;
}

bool wsSendString( const std::string &str ){
	bool success = false;
	if( ws_socket ){
		emscripten_websocket_send_utf8_text( ws_socket, str.c_str() );
		success = true;
	}
	return success;
}

#else

/*

void wsConnect( const std::string &uri, void (*f)(std::string) ){
	// Parse the URI.
	auto uri_parts = wsParseAddress( uri );
	if( uri_parts.size() < 6 ){
		Notify( &tex_net_notification, "Network Error", "Invalid URI" );
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
	}
	// Label the URI components.
	std::string
		scheme = uri_parts[2],
		authority = uri_parts[4],
		path = uri_parts[5];
	bool wss = scheme == "wss";
	if( path.empty() ) path = "/";
	// Query string.
	if( uri_parts.size() > 6 ) path += uri_parts[6];
	// Host and port.
	std::string host = authority;
	Poco::UInt16 port = wss ? 443 : 80;
	size_t colon_at = authority.find( ':' );
	if( colon_at != std::string::npos ){
		host = authority.substr( 0, colon_at );
		port = std::stoi( authority.substr( colon_at + 1 ) );
	}
	// Create a client session without connecting, per Poco's design.
	ws_client_session = wss
		? new Poco::Net::HTTPSClientSession()
		: new Poco::Net::HTTPClientSession();
	ws_client_session->setHost( host );
	ws_client_session->setPort( port );
	// HTTP proxy settings. TODO: Use wsParseAddress for this too.
	Poco::Net::HTTPClientSession::ProxyConfig pc;
	std::string p = settings.sections.get( "Misc" ).get( "http_proxy" );
	size_t first = p.length() < 3 ? std::string::npos : p.find( "://" );
	size_t last = p.empty() ? std::string::npos : p.find_last_of( ':' );
	std::string
		p0 = first == std::string::npos ? "" : p.substr( 0, first ),
		p1 = last == std::string::npos ? "" : p.substr( 0, last ),
		p2 = last == std::string::npos ? "0" : p.substr( last + 1 );
	pc.host = first == std::string::npos ? "" : p1.substr( first + 3 );
	pc.port = std::stoi( p2 );
	// TODO: Username and password detection.
	pc.username = "";
	pc.password = "";
	pc.authMethod = pc.username.empty()
		? ws_client_session->ProxyAuthentication::PROXY_AUTH_NONE
		: ws_client_session->ProxyAuthentication::PROXY_AUTH_HTTP_BASIC;
	// Check the settings.
	if( p0 == "http" && host != "localhost" && host != "127.0.0.1" ){
		ws_client_session->setProxyConfig( pc );
	}
	// Connect to the server.
	try{
		// Prepare a generic request to initialize the connection.
		Poco::Net::HTTPRequest
			request( Poco::Net::HTTPRequest::HTTP_GET, path, "HTTP/1.1" );
		// Prepare a hole for the response.
		Poco::Net::HTTPResponse response;
		// Connect to the WebSocket server.
		ws_socket = new Poco::Net::WebSocket(
			*ws_client_session,
			request,
			response
		);
		ws_socket->setMaxPayloadSize( ws_buffer_size );
		// 0.1 seconds.
		ws_socket->setReceiveTimeout( Poco::Timespan( 0, 100 * 1000 ) );
	}catch( Poco::Exception &e ){
		wsHandleException( e );
		return;
	}
	ws_socket_active = true;
	// TODO: Notify( &tex_net_notification, "Connected to", uri );
	ws_callback = f;
	// Spawn the WebSocket thread if it does not already exist.
	if( ws_thread.get_id() != std::thread::id() ) return;
	ws_thread = std::thread( [](){
		while( ws_socket ){
			int flags = 0, bytes_received = 0;
			// Receive bytes from the server.
			try{
				bytes_received = ws_socket->receiveFrame(
					(void*)ws_buffer,
					ws_buffer_size,
					flags
				);
			}catch( Poco::Exception &e ){
				// Continue on.
			}
			std::string to_send = "";
			// Critical section.
			ws_mutex.lock();
			if( !ws_socket_active ){
				// Orderly shutdown.
				ws_socket->shutdown();
				// Clean up the object.
				delete ws_socket;
				ws_socket = nullptr;
				// Clean up the client session.
				delete ws_client_session;
				// Return control to other threads.
				ws_mutex.unlock();
				// Exit the thread.
				return;
			}
			if( bytes_received > 0 )
				ws_callback( std::string( ws_buffer, bytes_received ) );
			if( ws_out.size() > 0 ){
				to_send = ws_out;
				ws_out = "";
			}
			ws_mutex.unlock();
			if( to_send.size() > 0 ){
				try{
					// Defaults to text mode.
					// In case of binary:
					//ws_socket.SendFlags::FRAME_BINARY
					int bytes_sent = ws_socket
						->sendFrame( to_send.data(), to_send.size() );
					if( bytes_sent < 0 ){
						// TODO: Condition reached, treat as error.
					}
				}catch( Poco::Exception &e ){
					wsHandleException( e );
					return;
				}
			}
		}
	} );
	ws_thread.detach();
}

std::smatch wsParseAddress( const std::string &uri ){
	// https://stackoverflow.com/questions/5620235/cpp-regular-expression-to-validate-url/31613265#31613265
	// https://tools.ietf.org/html/rfc3986#page-50
	std::regex uri_regex(
		R"(^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)",
		std::regex::extended
	);
	std::smatch matches;
	std::regex_match( uri, matches, uri_regex );
	return matches;
}

// Poco WebSocket exception handler.
void wsHandleException( Poco::Exception &e ){
	Notify(
		&tex_net_notification,
		"Network Error",
		e.displayText()
	);
	// Clean up the object.
	delete ws_socket;
	ws_socket = nullptr;
	// Clean up the client session.
	delete ws_client_session;
	// Return to the main menu.
	settings_display = false;
	inputTarget = nullptr;
	convo.go( "init.json" );
}

// Disconnect from the server.
void wsDisconnect(){
	// Critical section.
	ws_mutex.lock();
	ws_socket_active = false;
	ws_mutex.unlock();
}

// Send a string to the server.
bool wsSendString( const std::string &str ){
	bool success = false;
	// Critical section.
	ws_mutex.lock();
	if( ws_socket ){
		ws_out = str;
		success = true;
	}
	ws_mutex.unlock();
	return success;
}

*/

std::smatch wsParseAddress( const std::string &uri ){
	// https://stackoverflow.com/questions/5620235#31613265
	// https://tools.ietf.org/html/rfc3986#page-50
	std::regex uri_regex(
		R"(^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)",
		std::regex::extended
	);
	std::smatch matches;
	std::regex_match( uri, matches, uri_regex );
	return matches;
}

void wsHandleRead( boost::system::error_code ec, size_t n ){
	// https://github.com/boostorg/beast/issues/819
	// https://stackoverflow.com/questions/24414658
	// Critical section.
	ws_mutex.lock();
	if( ws_socket_active ){
		if( !ec && n > 0 && ws_buffer.size() > 0 ){
			// Got a message.
			std::string str(
				boost::asio::buffer_cast<const char*>( ws_buffer.data() ),
				ws_buffer.size()
			);
			ws_callback( str );
		}
		ws_buffer.clear();
		// Read the next message.
		if( ws_use_ssl ){
			ws_ssl->async_read( ws_buffer, wsHandleRead );
		}else{
			ws_plain->async_read( ws_buffer, wsHandleRead );
		}
	}
	ws_mutex.unlock();
}

void wsHandleWrite( boost::system::error_code ec, size_t n ){
	(void)ec;
	(void)n;
	// Critical section.
	ws_mutex.lock();
	if( ws_write_queue.size() <= 1 ){
		ws_write_queue.clear();
		ws_mutex.unlock();
		return;
	}
	// The first message has already been sent.
	ws_write_queue.erase( ws_write_queue.begin() );
	if( ws_socket_active ){
		// Handle the next outgoing message in the queue.
		auto buf = ws_net::buffer( ws_write_queue[0] );
		if( ws_use_ssl ){
			ws_ssl->async_write( buf, wsHandleWrite );
		}else{
			ws_plain->async_write( buf, wsHandleWrite );
		}
	}else{
		ws_write_queue.clear();
	}
	ws_mutex.unlock();
}

void wsConnect( const std::string &uri, void (*f)(std::string) ){
	// Parse the URI.
	auto uri_parts = wsParseAddress( uri );
	// Critical section.
	ws_mutex.lock();
	if( uri_parts.size() < 6 || !ws_ready ){
		Notify(
			&tex_net_notification,
			"Network Error",
			ws_ready ? "Invalid URI" : "Hung socket"
		);
		ws_mutex.unlock();
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
		return;
	}
	ws_mutex.unlock();

	// Label the URI components.
	std::string
		scheme = uri_parts[2],
		authority = uri_parts[4],
		path = uri_parts[5];
	ws_use_ssl = scheme == "wss";
	if( path.empty() ) path = "/";
	// Query string.
	if( uri_parts.size() > 6 ) path += uri_parts[6];
	// Host and port.
	std::string
		host = authority,
		port = ws_use_ssl ? "443" : "80";
	size_t colon_at = authority.find( ':' );
	if( colon_at != std::string::npos ){
		host = authority.substr( 0, colon_at );
		port = authority.substr( colon_at + 1 );
	}
	// Host and port must be present in requests.
	std::string target = host + ":" + port;

	if( ws_use_ssl ){
		// Delete the SSL WebSocket, which uses the old SSL context and
		// the old IO context.
		delete ws_ssl;
		// Rebuild the SSL context.
		delete ws_ctx;
		ws_ctx = new ws_net::ssl::context{ ws_net::ssl::context::tlsv12_client };
		try{
			#ifdef _WIN32
			// Windows Certificate Store is not compatible with OpenSSL.
			// https://github.com/nabla-c0d3/trust_stores_observatory
			ws_ctx->load_verify_file( ws_win32_pem );
			#else
			// Assumes OpenSSL-compatible trust store exists on system.
			ws_ctx->set_default_verify_paths();
			#endif
		}catch( std::exception const &e ){
			Notify(
				&tex_net_notification,
				"Network Error",
				e.what()
			);
			// Return to the main menu.
			settings_display = false;
			inputTarget = nullptr;
			convo.go( "init.json" );
			return;
		}
	}else{
		// Delete the unencrypted WebSocket, which uses the old IO
		// context.
		delete ws_plain;
	}

	// Rebuild the IO context.
	delete ws_ioc;
	ws_ioc = new ws_net::io_context;

	// Create a new WebSocket.
	if( ws_use_ssl ){
		ws_ssl = new ws_websocket::stream<boost::beast::ssl_stream<ws_net::ip::tcp::socket>>{ *ws_ioc, *ws_ctx };
	}else{
		ws_plain = new ws_websocket::stream<ws_net::ip::tcp::socket>{ *ws_ioc };
	}

	ws_net::ip::tcp::resolver resolver( *ws_ioc );

	ws_tcp_sock = ws_use_ssl
		? &ws_ssl->next_layer().next_layer()
		: &ws_plain->next_layer();

	// HTTP proxy settings. TODO: Use wsParseAddress for this too.
	std::string p = settings.sections.get( "Misc" ).get( "http_proxy" );
	size_t first = p.length() < 3 ? std::string::npos : p.find( "://" );
	size_t last = p.empty() ? std::string::npos : p.find_last_of( ':' );
	std::string
		p0 = first == std::string::npos ? "" : p.substr( 0, first ),
		p1 = last == std::string::npos ? "" : p.substr( 0, last ),
		p2 = last == std::string::npos ? "0" : p.substr( last + 1 );
	std::string
		p_host = first == std::string::npos ? "" : p1.substr( first + 3 );
	// TODO: Username and password detection.
	// Check the settings.
	bool proxy =
		p0 == "http" && host != "localhost" && host != "127.0.0.1";

	// Connect to the server.
	try{
		if( proxy ){
			// Look up the proxy server domain name
			auto const results = resolver.resolve( p_host, p2 );

			ws_net::connect( *ws_tcp_sock, results );

			boost::beast::http::request<boost::beast::http::string_body>
				req1{ boost::beast::http::verb::connect, target, 11 };
			req1.set( boost::beast::http::field::host, target );

			boost::beast::http::write( *ws_tcp_sock, req1 );

			boost::beast::flat_buffer buffer;

			boost::beast::http::response<boost::beast::http::empty_body> res;
			boost::beast::http::parser<false, boost::beast::http::empty_body> parser( res );
			parser.skip( true );
			boost::beast::http::read( *ws_tcp_sock, buffer, parser );
		}else{
			// Look up the domain name
			auto const results = resolver.resolve(host, port);

			// Make the connection on the IP address we get from a lookup
			ws_net::connect( *ws_tcp_sock, results );
		}

		if( ws_use_ssl ){
			// Perform SSL handshake and verify the remote host's certificate.
			ws_ssl->next_layer().set_verify_mode( ws_net::ssl::verify_peer );
			ws_ssl->next_layer().set_verify_callback( ws_net::ssl::rfc2818_verification( host ) );
			ws_ssl->next_layer().handshake( ws_net::ssl::stream_base::client );
			// Perform the websocket handshake
			ws_ssl->handshake( target, path );
		}else{
			// Perform the websocket handshake
			ws_plain->handshake( target, path );
		}

		// Disable Nagle's algorithm.
		ws_tcp_sock->set_option( ws_net::ip::tcp::no_delay( true ) );
	}catch( std::exception const &e ){
		Notify(
			&tex_net_notification,
			"Network Error",
			e.what()
		);
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
		return;
	}
	ws_socket_active = true;
	// TODO: Notify( &tex_net_notification, "Connected to", uri );
	ws_callback = f;

	// Asynchronous code.
	if( ws_use_ssl ){
		ws_ssl->async_read( ws_buffer, wsHandleRead );
	}else{
		ws_plain->async_read( ws_buffer, wsHandleRead );
	}
	// Spawn the WebSocket thread if it does not already exist.
	if( ws_thread.get_id() != std::thread::id() ) return;
	ws_thread = std::thread( [](){
		ws_ioc->run();
	} );
	ws_thread.detach();
}

// Disconnect from the server.
void wsDisconnect(){
	auto cleanup = []( boost::system::error_code ec ){
		(void)ec;
		ws_write_queue.clear();
		ws_ready = true;
	};
	// Critical section.
	ws_mutex.lock();
	// Guard against disconnecting something that was not connected.
	if( ws_socket_active ){
		ws_socket_active = false;
		ws_ready = false;
		if( ws_use_ssl ){
			ws_ssl->async_close( boost::beast::websocket::close_code::normal, cleanup );
		}else{
			ws_plain->async_close( boost::beast::websocket::close_code::normal, cleanup );
		}
	}
	multiplayer = false;
	ws_mutex.unlock();
}

// Send a string to the server.
bool wsSendString( const std::string &str ){
	bool success = false;
	// Critical section.
	ws_mutex.lock();
	if( ws_socket_active ){
		bool ready = ws_write_queue.empty();
		ws_write_queue.push_back( str );
		if( ready ){
			auto buf = ws_net::buffer( ws_write_queue[0] );
			if( ws_use_ssl ){
				ws_ssl->async_write( buf, wsHandleWrite );
			}else{
				ws_plain->async_write( buf, wsHandleWrite );
			}
		}
		success = true;
	}
	ws_mutex.unlock();
	return success;
}

#endif

// Uploads world data to convo and resets necessary variables.
// Call this before convo.go.
void vnDataUpload(){
	if( world.followEntity >= 0 && world.entities.size() >= 1 ){
		auto &player = world.entities[world.followEntity];
		convo.strings["name"] = player.name;
		convo.setVariable( "health", player.health );
		convo.setVariable( "money", player.money );
		convo.setVariable( "karma", player.karma );
		// Clear the global stats namespace using erase_if algorithm.
		for( auto it = convo.numbers.begin(), last = convo.numbers.end(); it != last; ){
			if( it->first.length() > 6 && it->first.substr( 0, 6 ) == "stats." ){
				it = convo.numbers.erase( it );
			}else{
				// Pre-incrementing an iterator is faster because
				// post-increment must make a copy of the previous value
				// and return it.
				++it;
			}
		}
		// Copy the player's bribeItems into the VN stats namespace.
		for( auto &item : player.bribeItems ){
			convo.setVariable( "stats." + item.first, item.second );
		}
	}
	convo.setVariable( "day", day );
	convo.setVariable( "multiplayer", multiplayer );
	convo.setVariable( "mainmenu", mainmenu );
	// Disable display of settings menu where it isn't explicitly
	// selected.
	settings_display = false;
	inputTarget = nullptr;
	// If the last screen was a settings menu, save the settings.
	if( convo.screen.id == "SETTINGS" )
		SaveSettings( user_data_path + "/settings.ini" );
}

// Loads the necessary images and updates data to display on the screen.
// Call this after convo.go.
void vnDataDownload( const std::string &data_path ){
	for( const auto &s : convo.screens ){
		if( findBackground( s.bg ) == -1 ){
			backgrounds.push_back( {
				s.bg,
				loadImage( data_path + "/bg/" + s.bg, true, true )
			} );
		}
		if( findForeground( s.fg ) == -1 ){
			foregrounds.push_back( {
				s.fg,
				loadImage( data_path + "/fg/" + s.fg, true, true )
			} );
		}
	}
	// Make data from convo accessible to the VN part of the game.
	bgIndex = findBackground( convo.screen.bg );
	fgIndex = findForeground( convo.screen.fg );
	long long old_health = health;
	health = convo.getVariable( "health" );
	money = convo.getVariable( "money" );
	multiplayer = convo.getVariable( "multiplayer" );
	mainmenu = convo.getVariable( "mainmenu" );
	if( day == 0 ){
		day = convo.getVariable( "day" );
	}
	// Apply VN variables IF the player is not ALREADY dead or
	// uninitialized.
	if( world.followEntity >= 0 && world.entities.size() >= 1
		&& ( old_health != 0 || health != 0 ) ){
		auto &player = world.entities[world.followEntity];
		player.name = StringSanitize( convo.strings["name"] );
		player.health = health;
		player.money = money;
		player.karma = convo.getVariable( "karma" );
		// Copy VN stats into the player's bribeItems.
		for( auto &p : convo.numbers ){
			if( p.first.length() > 6 && p.first.substr( 0, 6 ) == "stats." )
				player.bribeItems.set( p.first.substr( 6 ), p.second );
		}
	}
	// Override the CANCEL ID.
	for( auto &id : convo.screen.ids ){
		if( id == "CANCEL" ){
			id = vn_callback_file;
		}
	}
	if( language == "zh" ){
		// Translate English to Mandarin Chinese.
		if( convo.screen.caption.length() > 0 )
			convo.screen.caption = EnToZh( convo.screen.caption );
		for( std::string &line : convo.screen.lines ){
			if( line.length() > 0 ) line = EnToZh( line );
		}
	}
	// Select the first item if using non-mouse mode.
	if( !show_cursor ) choiceIndex = 0;
}

void vnDraw( double sw, double sh ){
	double aspect = sw / sh;
	double vnScale = sw / 1280.0;
	double vnOffX = 0.0, hudOffX = 0.0;
	double vnMinAspect = 5.0 / 4.0, vnMaxAspect = 1.78;
	if( aspect < vnMinAspect ){
		vnScale = sh * vnMinAspect / 1280.0;
		vnOffX = ( sw - sh * vnMinAspect ) * 0.5;
	}else if( aspect > vnMaxAspect ){
		vnScale = sh * vnMaxAspect / 1280.0;
		vnOffX = ( sw - sh * vnMaxAspect ) * 0.5;
		hudOffX = vnOffX;
	}

	// Configure the pipeline for 2D drawing.
	//fgl::setPipeline( fgl::unlitPipeline );
	fgl::setPipeline( fgl::colorModPipeline );
	glDisable( GL_DEPTH_TEST );
	glEnable( GL_BLEND );
	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
	//fgl::setFog( { 0.0, 0.0, 0.0, 0.0 } );
	fgl::setFog( { 1.0f, 1.0f, 1.0f, 1.0f } );

	if( paused )
		drawImage( shadeBox, 0.0f, 0.0f, 0.0f, sw, sh );

	if( convo.screen.bg.length() > 0 && bgIndex >= 0
		&& backgrounds[bgIndex].imgTexture.success ){
		// Draw background image.
		drawImage(
			backgrounds[bgIndex].imgTexture,
			vnOffX,
			0.0,
			0.0,
			vnScale,
			vnScale
		);
	}

	// Draw foreground.
	if( fgIndex >= 0 ){
		drawImage(
			foregrounds[fgIndex].imgTexture,
			vnOffX,
			0.0,
			0.0,
			vnScale,
			vnScale
		);
	}

	float statScale = 1.0f;
	fgl::Color statColor = { 1.0f, 1.0f, 1.0f, 1.0f };
	std::string stats = "";
	// Display stats only if health > 0 and settings menu is not open.
	if( health > 0 && !settings_display ){
		stats = u8"♥" + std::to_string( health ) + " Day" + std::to_string( day );
		// Money need not be shown until some is acquired.
		if( money != 0 ){
			stats += " $" + std::to_string( money );
		}
		// Draw the box behind the stats.
		drawImage(
			shadeBox,
			0.0,
			0.0,
			0.0,
			hudOffX + fgl::getTextWidth( stats, font_mono ) * vnScale * statScale,
			font_mono.height * vnScale * statScale
		);
	}else if( mainmenu ){
		// Display the version number and controller status.
		statScale = 0.5f;
		statColor = { 0.55f, 0.55f, 0.55f, 1.0f };
		stats = app_version;
	}

	float tw = fgl::getTextWidth( convo.screen.caption, font_vn );
	bool hasNewline =
		convo.screen.caption.find_first_of( '\n' ) != std::string::npos;
	float tlines =
		( tw > std::min( 1280.0, sw / vnScale ) || hasNewline )
		? 2.0f : ( convo.screen.caption.length() > 0 ? 1.0f : 0.0f );
	float ypos =
		( 533.0 - font_vn.size * 1.2 * tlines - font_vn.size * 0.17 ) * vnScale;

	if( !paused && convo.screen.ids.size() > 0
		&& ( ( bgIndex >= 0 && backgrounds[bgIndex].imgTexture.success )
			|| ( convo.screen.bg.empty() && !world.map.empty() ) ) ){
		// There are buttons and something to draw a shade over.
		drawImage(
			shadeBox,
			vnOffX,
			ypos + ( tlines > 0.0f ? 2.0f - tlines : 8.0f ) * vnScale,
			0.0f,
			1280.0 * vnScale,
			600.0 * vnScale
		);
	}

	// Caption.
	glBlendFunc( GL_ONE, GL_ONE );
	fgl::drawText(
		convo.screen.caption,
		font_vn,
		vnOffX + 640.0 * vnScale,
		ypos,
		vnScale,
		1,
		std::min( 1280.0 * vnScale, sw )
	);

	// HUD.
	fgl::setFog( statColor );
	fgl::drawText(
		//dungeon.ready ? stats + "\n" + std::to_string( showFrames ) : stats,
		stats,
		font_mono,
		hudOffX,
		0.0,
		vnScale * statScale
	);
	fgl::setFog( { 1.0f, 1.0f, 1.0f, 1.0f } );

	// Draw buttons.
	glBlendFunc( GL_ZERO, GL_ONE_MINUS_SRC_COLOR );

	// Non-mouse navigation of buttons.
	if( choiceIndex >= (int)convo.screen.ids.size()
		&& !settings_display && !character_select_display ){
		choiceIndex = -1;
	}
	if( convo.screen.ids.empty() && !character_select_display ){
		choiceIndex = show_cursor ? -1 : 0;
	}else if( moveYDown && choiceIndex == -1 ){
		choiceIndex = settings_display ? convo.screen.ids.size() : 0;
	}else if( moveYDown ){
		choiceIndex += moveYDown;
		int num_properties = settings.global.size();
		if( settings_category.length() > 0
			&& settings.sections.contains( settings_category ) ){
			num_properties =
				settings.sections.get( settings_category ).size();
		}
		if( choiceIndex < 0 ){
			choiceIndex = settings_display
				? convo.screen.ids.size() + num_properties - 1 : 0;
		}else if( choiceIndex == (int)convo.screen.ids.size()
			&& !settings_display ){
			choiceIndex = convo.screen.ids.size() - 1;
		}else if( choiceIndex == (int)convo.screen.ids.size()
			+ num_properties ){
			choiceIndex = 0;
		}
	}

	float buttonHeight = font_vn.size * 1.333f;

	for( size_t i = 0; i < convo.screen.ids.size(); i++ ){
		float ypos = ( 533.0f + buttonHeight * (float)i ) * vnScale;
		bool hover =
			!inputTarget &&
			show_cursor &&
			fgl::mouseY >= ypos &&
			fgl::mouseY < ( 533.0f + buttonHeight * (float)( i + 1 ) ) * vnScale &&
			fgl::mouseX >= vnOffX &&
			fgl::mouseX < vnOffX + 1280.0 * vnScale;
		if( hover ){
			choiceIndex = i;
		}
		glDisable( GL_BLEND );
		drawImage(
			i == (size_t)choiceIndex ? button_p : button_n,
			vnOffX,
			ypos,
			0.0,
			1280.0 * vnScale / button_n.width,
			buttonHeight * vnScale / button_n.height
		);
		glEnable( GL_BLEND );
		fgl::drawText(
			convo.screen.lines.size() > i
				? convo.screen.lines[i]
				: ( language == "zh" ? u8"继续 →" : u8"Continue →" ),
			font_vn,
			vnOffX + 640.0 * vnScale,
			ypos,
			vnScale,
			1
		);
	}

	if( choiceIndex >= 0 && choiceIndex < (int)convo.screen.ids.size()
		&& ( mouseDownLeft || actionButtonDown ) ){
		// Do not propagate the click or button press.
		mouseDownLeft = false;
		actionButtonDown = false;
		vnDataUpload();
		std::string id = convo.screen.ids[choiceIndex];
		if( id == vn_callback_file ){
			// Returning to the previous screen. Clear the save path.
			save_path = "";
		}
		convo.go( id );
		vnDataDownload( data_path );
		// Unpause if there is nothing mandating a paused game.
		if( ( convo.screen.ids.empty() || mainmenu ) && !recipes_display
			&& !settings_display && !character_select_display ){
			paused = false;
			if( trading_display && trading_entity ){
				// Stop trading.
				trading_display = false;
				trading_entity = nullptr;
			}
		}
	}
}

double SEval( std::string expression ){
	// Token separators.
	static const char* separators = "\t\n\r\x20()";
	// Expression is zero if it is too short or empty.
	if( expression.length() < 3 || expression[1] == ')' ) return 0.0;
	// Find the start point of the token.
	size_t start_at = expression.find_first_not_of( separators );
	// Expression is zero if it contains only separators.
	if( start_at == std::string::npos ) return 0.0;
	// Find the first separator after the token.
	size_t end_at = expression.find_first_of( separators, start_at );
	// Expression is zero if it does not contain an end separator.
	if( end_at == std::string::npos ) return 0.0;
	// TODO: Parse the expression, handle strings and lists.
	return 0.0;
}

void LoadSettings( std::string file_name ){
	// Load the file.
	FILE* file = fopen( file_name.c_str(), "rb" );
	// Fail silently to allow no-op loading of files yet to be created.
	if( !file ) return;
	std::string data = "";
	char buf[4096];
	while( size_t len = fread( buf, 1, sizeof( buf ), file ) ){
		data += std::string( buf, len );
	}
	fclose( file );
	// Parse the file.
	ini_t *ini = ini_load( data.c_str(), nullptr );
	int section_count = ini_section_count( ini );
	// Loop through sections.
	for( int s = 0; s < section_count; s++ ){
		IniSection *inisec = &settings.global;
		if( s != INI_GLOBAL_SECTION ){
			std::string secname( ini_section_name( ini, s ) );
			if( !settings.sections.contains( secname ) )
				settings.sections.set( secname, {} );
			inisec = &settings.sections.get( secname );
		}
		int property_count = ini_property_count( ini, s );
		for( int p = 0; p < property_count; p++ ){
			std::string
				name( ini_property_name( ini, s, p ) ),
				value( ini_property_value( ini, s, p ) );
			// Add the data to settings.
			inisec->set( name, value );
		}
	}
	// Destroy the in-memory INI data.
	ini_destroy( ini );
}

void SaveSettings( std::string file_name ){
	// Generate INI data.
	ini_t *ini = ini_create( nullptr );
	for( auto &property : settings.global ){
		ini_property_add(
			ini,
			INI_GLOBAL_SECTION,
			property.first.c_str(),
			0,
			property.second.empty() || property.second[0] < '!'
				? settings_none.c_str() : property.second.c_str(),
			0
		);
	}
	for( auto &section : settings.sections ){
		int s = ini_section_add( ini, section.first.c_str(), 0 );
		for( auto &property : section.second ){
			ini_property_add(
				ini,
				s,
				property.first.c_str(),
				0,
				property.second.empty() || property.second[0] < '!'
					? settings_none.c_str() : property.second.c_str(),
				0
			);
		}
	}
	std::string data = "";
	data.resize( ini_save( ini, nullptr, 0 ) );
	ini_save( ini, &data.front(), data.size() );
	// Remove null byte if present. (TODO: Possible bug in INI loader.)
	if( data.back() == '\0' ) data.pop_back();
	// Save the file to Steam Cloud.
	#ifdef __STEAM__
		if( use_steam_cloud && !SteamRemoteStorage()->FileWrite(
				"settings.ini",
				(void*)data.c_str(),
				(int32)data.size() ) ){
			fprintf( stderr, "Failed to write settings.ini\n" );
		}
	#endif
	// Save the file to the local filesystem.
	FILE* file = fopen( file_name.c_str(), "wb" );
	if( file ){
		fwrite( data.c_str(), data.size(), 1, file );
		fclose( file );
	}else{
		fprintf( stderr, "Failed to open %s for writing\n", file_name.c_str() );
	}
	// Destroy the in-memory INI data.
	ini_destroy( ini );
	// Handle globals that must be managed immediately.
	// Set the global volume.
	IniSection &misc = settings.sections.get( "Misc" );
	char* p;
	global_volume =
		std::strtof( misc.get( "global_volume" ).c_str(), &p ) * 0.01f;
	if( *p ) global_volume = 1.0f;
	// Stop all sounds. The engine will hopefully restart them.
	cs_stop_all_sounds( csctx );
}

void InitSettings(){
	// Default settings.
	settings = {};
	settings.sections.set( "Keyboard", {} );
	IniSection &kb = settings.sections.back().second;
	// Get the current key names from QWERTY scancodes.
	kb.set(
		"up",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_W ) )
	);
	kb.set(
		"down",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_S ) )
	);
	kb.set(
		"left",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_A ) )
	);
	kb.set(
		"right",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_D ) )
	);
	kb.set(
		"melee",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_F ) )
	);
	kb.set(
		"place_item",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_E ) )
	);
	kb.set(
		"recipes_menu",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_R ) )
	);
	kb.set(
		"rest",
		SDL_GetKeyName( SDL_GetKeyFromScancode( SDL_SCANCODE_T ) )
	);
	// Misc. settings.
	settings.sections.set( "Misc", {} );
	IniSection &misc = settings.sections.back().second;
	misc.set( "global_volume", "100" );
	misc.set( "language_code", settings_none );
	// 4381-4386 is an unassigned/unused port range.
	misc.set( "test_server", "wss://ccserver.mobilegamedev.org/" );
	#ifndef __EMSCRIPTEN__
		char* http_proxy = std::getenv( "http_proxy" );
		misc.set( "http_proxy", http_proxy ? http_proxy : "" );
	#endif
	#ifndef __LIGHT__
		misc.set( "PBR", "0" );
	#endif
}

void InitCloud(){
	#ifdef __STEAM__
		if( use_steam_cloud ){
			auto cloud = SteamRemoteStorage();
			// Enumerate and copy all files from Steam Cloud.
			int32 num = cloud->GetFileCount();
			for( int32 i = 0; i < num; i++ ){
				int32 file_size;
				const char* file_name =
					cloud->GetFileNameAndSize( i, &file_size );
				std::string file_path_local =
					user_data_path + "/" + file_name;
				// Get the local file's timestamp.
				struct stat attrib;
				stat( file_path_local.c_str(), &attrib );
				auto file_timestamp_local =
				#if defined(_WIN32) or defined(__APPLE__)
					// TODO: May not be 2038-proof.
					attrib.st_mtime;
				#else // GNU/Linux
					attrib.st_mtim.tv_sec;
				#endif
				// Get the Steam Cloud file's timestamp.
				auto file_timestamp_cloud =
					cloud->GetFileTimestamp( file_name );
				// Copy the file from Steam Cloud if it is newer.
				FILE *file;
				if( file_timestamp_cloud > file_timestamp_local
					&& ( file = fopen( file_path_local.c_str(), "wb" ) ) ){
					void *buf = malloc( file_size );
					int32 bytes_read = cloud->FileRead(
						file_name,
						buf,
						file_size
					);
					if( bytes_read ){
						fwrite( buf, (size_t)bytes_read, 1, file );
					}else{
						fprintf(
							stderr,
							"Failed to read %s from Steam Cloud",
							file_name
						);
					}
					free( buf );
					fclose( file );
				}
			}
		}
	#endif
}

// Delete all files containing saved games, settings, and recipes.
void DeleteAllUserFiles(){
	#ifdef __STEAM__
		if( use_steam_cloud ){
			auto cloud = SteamRemoteStorage();
			// Enumerate and delete all files stored in Steam Cloud.
			int32 num = cloud->GetFileCount();
			for( int32 i = 0; i < num; i++ ){
				int32 file_size;
				cloud->FileDelete( cloud->GetFileNameAndSize( i, &file_size ) );
			}
		}
	#endif
	// Delete files in the local user data folder.
	for( size_t i = 1; i <= save_slots; i++ ){
		std::string save_file =
			user_data_path + "/" + std::to_string(i) + ".json";
		remove( save_file.c_str() );
	}
	remove( ( user_data_path + "/settings.ini" ).c_str() );
	remove( ( user_data_path + "/" + user_recipes ).c_str() );
}

// Called when a fighter hits something.
void HitCallback( fworld::Entity *ent_a, fworld::Entity *ent_b, int damage ){
	if( ent_b->task == fworld::TASK_SLEEP ){
		// Wake the entity.
		ent_b->task = fworld::TASK_NONE;
		ent_b->stun = 0.0;
		if( world.followEntity >= 0
			&& world.entities.size() > (size_t)world.followEntity
			&& ent_b == &world.entities[world.followEntity] ){
			// The player is hit, so turn off sleep mode.
			sleep_mode = false;
		}
	}
	// Blood.
	MakeGibs(
		ent_b->x + 0.5,
		ent_b->y + 1.0 - ent_b->height * 0.75 / world.tileSize,
		linalg::normalize( linalg::vec<double,3>( ent_b->x - ent_a->x, ent_b->y - ent_a->y, 0.0 ) ) * 10.0,
		std::max( damage, 3 )
	);
	// Controller rumble.
	float intensity = std::fminf( std::max( damage, 3 ) / 50.0f, 1.0f );
	// Assuming conventional rumblers (motor with asymmetrical weight).
	// The uppermost third of rumble voltage delivers the desired effect.
	// ~150 ms is the minimum duration for rumblers to work.
	fgl::hapticRumble(
		lerp( 0.667, 1.0, intensity ),
		std::max( (int)( intensity * 1000 ), 150 )
	);
	// Sound.
	csPlaySound( "melee_hit", false );
}

void Notify( fgl::Texture *texture, std::string s1, std::string s2 ){
	if( language == "zh" ){
		// Translate English to Mandarin Chinese.
		s1 = EnToZh( s1 );
		s2 = EnToZh( s2 );
	}
	popups.push_back( {
		texture,
		s1,
		s2,
		popup_countdown,
		true
	} );
}

void AddRecipe( std::string name ){
	auto &r = recipes[name];
	if( !r.unlocked ){
		r.unlocked = true;
		// Autosave.
		SaveGame( 1 );
		Notify( &tex_notification, "New recipe!", name );
	}
}

void Achieve( std::string id ){
	#ifdef __STEAM__
		if( use_steam && SteamUserStats() ){
			SteamUserStats()->SetAchievement( id.c_str() );
			SteamUserStats()->StoreStats();
		}
	#else
		// Other platforms' achievement APIs go here.
		(void)id;
	#endif
}

// Add an entity to the 2D world at (x,y) with optional index idx.
void AddEntity( fworld::Entity &ent, double x, double y, int idx, bool recalc ){
	bool add_plant = ( ent.type == "flora" );
	if( idx >= 0 ){
		// Let the subsystems do their own bookkeeping.
		world.entities[idx] = ent;
	}else{
		idx = world.entities.size();
		world.entities.push_back( ent );
		// Do bookkeeping for the subsystems.
		if( add_plant ){
			flora::Plant p = {};
			p.entity_index = idx;
			// Only plants in the first 3 growth phases are fertile.
			p.fertility = ent.age < 3.0 ? 1.0 : 0.0;
			plants.push_back( p );
		}else if( ent.type == "civilian" || ent.type == "bird" ){
			civilian::Civilian c = {};
			c.entity_index = idx;
			ent.task = fworld::TASK_NONE;
			civilians.push_back( c );
		}else if( ent.type == "fighter" ){
			fighter::Fighter f = {};
			f.entity_index = idx;
			f.minimum_health = 20;
			ent.task = fworld::TASK_NONE;
			fighters.push_back( f );
		}
	}
	fworld::Entity &new_ent = world.entities[idx];
	new_ent.x = x;
	new_ent.y = y;
	if( add_plant ){
		flora::FixEntityTile( idx, &world, add_plant );
	}else if( ent.staticCollisions ){
		// Only recalculate when absolutely necessary.
		if( recalc ) world.recalculateMapEntities();
		world.mapChanged = true;
	}
}

int GetEntityAt( double x, double y ){
	long long
		lx = (long long)( x + 0.5 ),
		ly = (long long)( y + 0.5 );
	for( int i = 0; i < (int)world.entities.size(); i++ ){
		auto &ent = world.entities[i];
		if( (long long)( ent.x + 0.5 ) == lx
			&& (long long)( ent.y + 0.5 ) == ly ){
			return i;
		}
	}
	return -1;
}

int GetItemHealth( fworld::Entity &entity, fworld::Item &item ){
	// Get the amount the item will change the entity's health.
	// TODO: Health scale factor.
	int item_health =
		( item.flavor * 0.4
		+ item.appearance * 0.3
		+ item.lactose * 0.3 )
		* std::min( ( item.sweetness + 1.0 ) / ( entity.sweetTooth + 1.0 ), 1.0 );
	if( item.lactose > entity.lactoseTolerance )
		item_health = 0;
	return item_health;
}

int GetItemPayPrice( fworld::Entity &entity, fworld::Item &item ){
	// Get the amount the entity will pay for the item.
	// Entities can be manipulated into buying low-health items.
	// TODO: If a sold item makes an entity sick, the player's karma
	// should be reduced.
	if( world.followEntity < 0 || world.entities.empty() )
		return 0;
	auto &player = world.entities[world.followEntity];
	// Several entities will have a karma distance of 50, and several
	// items will have a base price of 2. That price becomes 1.
	double karma_factor =
		( 100.0 - std::abs( player.karma - entity.karma ) ) / 100.0;
	return std::max( (int)std::ceil( item.price * karma_factor ), 0 );
}

void Eat( fworld::Entity &entity, fworld::Item &item ){
	int item_health = GetItemHealth( entity, item );
	if( item_health <= 0 ){
		entity.health -= 25; // TODO: An arbitrary number defined elsewhere.
		// TODO: Trigger entity vomit animation.
	}else if( entity.health < max_health ){
		entity.health += item_health;
		if( entity.health > max_health )
			entity.health = max_health;
	}
}

void KillEntity( fworld::Entity &entity ){
	entity.type = "corpse";
	entity.task = fworld::TASK_SLEEP;
	entity.path.clear();
	if( entity.deathDialogue.length() > 0 ){
		// Open the death dialogue.
		vnDataUpload();
		convo.go( entity.deathDialogue );
		vnDataDownload( data_path );
	}
}

void RegenerateZone( int zone, uint32_t seed ){
	if( zone < 0 || (size_t)zone >= world.entities.size()
		|| world.followEntity < 0 ){
		return;
	}
	auto &zone_ent = world.entities[zone];
	if( zone_ent.type.length() < 6 ) return;
	std::string gen_type = zone_ent.type.substr( 5 );
	auto &player = world.entities[world.followEntity];
	long long px = player.x + 0.5, py = player.y + 0.5;
	long long
		x1 = zone_ent.x + 1,
		y1 = zone_ent.y + 1,
		x2 = zone_ent.x + zone_ent.width / world.tileSize + 1,
		y2 = zone_ent.y + zone_ent.height / world.tileSize + 1;
	// The zone index may change when items are deleted.
	zone = world.clearZone( zone );
	if( gen_type == "gen1" ){
		last_zone = zone;
		float density = 0.1f;
		// Items used by the generator.
		if( world.items.find( "gen_ostrich_warrior" ) == world.items.end()
			|| world.items.find( "gen_basket1" ) == world.items.end()
			|| world.items.find( "gen_basket2" ) == world.items.end()
			|| world.items.find( "gen_basket3" ) == world.items.end()
			|| world.items.find( "gen_pine" ) == world.items.end()
			|| world.items.find( "gen_pine_snow" ) == world.items.end() ){
			return;
		}
		// Monte Carlo domain of entities.
		std::vector<std::pair<fworld::Entity*,float>> mc = {
			{ &world.items["gen_ostrich_warrior"].entity, 0.01f },
			{ &world.items["gen_basket1"].entity, 0.0075f },
			{ &world.items["gen_basket2"].entity, 0.0075f },
			{ &world.items["gen_basket3"].entity, 0.0075f },
			{ &world.items["gen_pine"].entity, 0.5f },
			{ &world.items["gen_pine_snow"].entity, 0.5f }
		};
		bool prev_blocking = false;
		// Iterate through all non-blocked tiles in the zone.
		for( long long y = y1; y < y2; y++ ){
			for( long long x = x1; x < x2; x++ ){
				bool too_close = std::abs( x - px ) < 3
					&& std::abs( y - py ) < 3;
				if( !prev_blocking && !world.tileBlocking( x, y, true )
					&& ( multiplayer || !too_close )
					&& proc::RandFloat( &seed ) < density ){
					// TODO: Brute force tiles, ignoring blocked and player tiles.
					AddEntity( *proc::ProbObject( &seed, mc ), x, y, -1, false );
					prev_blocking = true;
				}else{
					prev_blocking = false;
				}
			}
		}
	}
	// TODO: Recalculate flora etc.
	world.recalculateMapEntities();
}

// Pull save metadata from the specified slot if != 0 or all slots if 0.
void SyncSaveData( size_t slot ){
	if( slot ){
		// Works with an arbitrary number of save slots.
		std::string n = std::to_string( slot );
		fworld::Entity ent = GetMapSpawn(
			data_path,
			user_data_path + "/" + n + ".json"
		);
		// Index save_names as slot minus 1 (0-indexed vs 1-indexed).
		if( save_names.size() < slot )
			save_names.resize( slot );
		save_names[slot - 1] = ent.name;
		convo.setVariable( "health" + n, ent.health );
		convo.setVariable( "day" + n, (long long)( ent.age / day_duration + 1.0 ) );
		convo.setVariable( "money" + n, ent.money );
	}else{
		// Works with a fixed number of save slots (usually 3).
		save_names.clear();
		for( size_t i = 1; i <= save_slots; i++ ){
			std::string n = std::to_string( i );
			fworld::Entity ent = GetMapSpawn(
				data_path,
				user_data_path + "/" + n + ".json"
			);
			save_names.push_back( ent.name );
			convo.setVariable( "health" + n, ent.health );
			convo.setVariable( "day" + n, (long long)( ent.age / day_duration + 1.0 ) );
			convo.setVariable( "money" + n, ent.money );
		}
	}
}

void SoundSpecLoad( std::string sounds_path, std::string spec_path ){
	// Short-circuit if the specified sound spec is already loaded.
	if( spec_path == sound_spec_name ) return;
	sound_spec_name = spec_path;

	// Stop all sounds.
	cs_stop_all_sounds( csctx );

	// Load the spec file.
	FILE* file = FileOpen( spec_path.c_str(), "rb" );
	if( !file ){
		fprintf( stderr, "Failed to open %s\n", spec_path.c_str() );
		return;
	}
	std::string text = "";
	char buf[4096];
	while( size_t len = fread( buf, 1, sizeof( buf ), file ) ){
		text += std::string( buf, len );
	}
	fclose( file );

	// Parse the spec file.
	auto allocatedDocument = jsonAllocateDocumentEx( text.c_str(), text.size(), JSON_READER_JSON5 );

	if( allocatedDocument.document.error.type != JSON_OK ){
		fprintf( stderr, "JSON error: %s at line %lu:%lu of %s\n\n",
			jsonGetErrorString( allocatedDocument.document.error.type ),
			(long unsigned int)allocatedDocument.document.error.line,
			(long unsigned int)allocatedDocument.document.error.column,
			spec_path.c_str() );
		jsonFreeDocument( &allocatedDocument );
		return;
	}

	// Convert JsonStringView to std::string.
	auto viewToString = []( JsonStringView str ){
		return std::string( str.data, str.size );
	};

	for( auto &o : allocatedDocument.document.root.getObject() ){
		// Make sounds without files null.
		if( !o.value["file"].getString().size ){
			sound_specs[viewToString( o.name )] = {
				nullptr,
				0.0f,
				0,
				0.0f,
				0.0f,
				0.0f
			};
			continue;
		}
		// Load the sound.
		csLoadSound(
			viewToString( o.name ),
			sounds_path + "/" + viewToString( o.value["file"].getString() ),
			o.value["volume"].getFloat(),
			o.value["reverbEchoes"].getInt(),
			o.value["reverbVolumeFactor"].getFloat(),
			o.value["reverbDelay"].getFloat(),
			o.value["reverbDelayFactor"].getFloat()
		);
	}

	jsonFreeDocument( &allocatedDocument );
}

void SaveGame( size_t slot ){
	// Short-circuit if player is dead or on the main menu.
	if( health <= 0 || convo.getVariable( "mainmenu" ) )
		return;
	// Save the map to the specified slot.
	if( world.map.size() > 0 ){
		std::string map_file = std::to_string( slot ) + ".json";
		std::string map_path_local = user_data_path + "/" + map_file;
		// Save the file locally.
		world.saveMap( map_path_local );
		#ifdef __STEAM__
			if( use_steam_cloud ){
				// Load the file from disk and save it to Steam Cloud.
				FILE* file = fopen( map_path_local.c_str(), "rb" );
				if( file ){
					// Get the file's size.
					fseek( file, 0, SEEK_END );
					size_t buf_size = ftell( file );
					fseek( file, 0, SEEK_SET );
					// Read the file into a buffer.
					void *buf = malloc( buf_size );
					bool read_error =
						buf_size && !fread( buf, buf_size, 1, file );
					fclose( file );
					if( !read_error && !SteamRemoteStorage()->FileWrite(
							map_file.c_str(),
							buf,
							(int32)buf_size ) ){
						fprintf(
							stderr,
							"Failed to write %s\n",
							map_file.c_str()
						);
					}
					free( buf );
				}
			}
		#endif
		// Sync save metadata.
		SyncSaveData( slot );
	}
	// Save a list of the user's unlocked recipes.
	if( recipes.empty() )
		return;
	// Save the recipes to Steam Cloud and a separate local file.
	std::string rec;
	for( auto &r : recipes ){
		rec += r.first + "\n";
	}
	#ifdef __STEAM__
		if( use_steam_cloud && !SteamRemoteStorage()->FileWrite(
				user_recipes.c_str(),
				(void*)rec.c_str(),
				(int32)rec.size() ) ){
			fprintf( stderr, "Failed to write %s\n", user_recipes.c_str() );
		}
	#endif
	std::string recpath = user_data_path + "/" + user_recipes;
	FILE* file = fopen( recpath.c_str(), "wb" );
	if( file ){
		fprintf( file, "%s", rec.c_str() );
		fclose( file );
	}else{
		fprintf( stderr, "Failed to open %s for writing\n", recpath.c_str() );
	}
}

// Displays a loading screen.
void LoadingScreen( std::string text, fgl::Font &font, float scale ){
	//fgl::setFramebuffer();
	//fgl::setPipeline( fgl::unlitPipeline );
	fgl::setPipeline( fgl::colorModPipeline );
	glDisable( GL_BLEND );
	auto tmp_fog = fgl::fogColor;
	//fgl::setFog( { 0.0f, 0.0f, 0.0f, 0.0f } );
	fgl::setFog( { 1.0f, 1.0f, 1.0f, 1.0f } );
	//fgl::cls( fgl::fogColor );
	//fgl::cls( { 0.0f, 0.0f, 0.0f, 0.0f } );
	fgl::drawText(
		text,
		font,
		fgl::getDisplayWidth() / 2,
		( fgl::getDisplayHeight() - font.height * scale ) / 2,
		scale,
		1
	);
	fgl::sync();
	fgl::setFog( tmp_fog );
}

// Transport the player to a different location using dialogue callback
// syntax.
void Warp( std::string param ){
	if( fadeTo.length() > 0 )
		return;
	// Prepend # to skip fading and saving.
	if( param.length() >= 2 && param[0] == '#' ){
		LoadMap( param.substr( 1 ), false );
		return;
	}
	bool is_dungeon =
		param.length() >= 9 && param.substr( 0, 8 ) == "DUNGEON ";
	if( !is_dungeon && param.length() >= 8
		&& param.substr( 0, 8 ) == "AUTOSAVE" ){
		save_path = user_data_path;
		// Replace the "AUTOSAVE" portion of the string with "1.json".
		param = "1.json"
			+ ( param.length() >= 9 ? param.substr( 8 ) : "" );
	}
	// Check if a map is already loaded.
	if( (
		#ifndef __LIGHT__
			dungeon.ready ||
		#endif
		( world.followEntity >= 0 && world.entities.size() >= 1 ) ) &&
		( is_dungeon
		|| ( param.length() >= 4 && param.substr( 0, 4 ) == "WARP" ) ) ){
		// A map is already loaded.
		fadeTo = param;
		return;
	}
	// Construct a full file path.
	std::string file_path = "";
	if( param.length() > 0 ){
		if( is_dungeon ){
			file_path = param;
		}else{
			// Load a map file from either the save path or the
			// game's data path.
			file_path =
				( save_path.length() > 0 ? save_path : data_path ) + "/" + param;
		}
	}
	// The mainmenu variable is used several times here, so cache it
	// without waiting for VN functions.
	mainmenu = convo.getVariable( "mainmenu" );
	// Short-circuit if on the main menu and the same 2D map is already
	// loaded.
	if( mainmenu
		&& file_path.length() > 0 && file_path == world.mapFile ){
		return;
	}
	// Load immediately if the game is paused, a background string
	// is specified, or fading seems like a bad idea.
	if( paused || mainmenu || convo.screen.bg.length() > 0
		|| ( is_dungeon ? ( true
		#ifndef __LIGHT__
			&& !dungeon.ready
		#endif
		) : world.map.empty() ) ){
		// As this is often used for initial world loads, the wait
		// can be a second or more. Therefore, it's a good idea to
		// show a loading screen.
		if( file_path.length() > 0 ){
			LoadingScreen(
				"...",
				font_mono,
				fgl::getDisplayHeight() / (float)simScreenHeight2D
			);
		}
		if( save_path.length() > 0 ){
			// Loading a saved game. Load the default sound specs.
			SoundSpecLoad(
				data_path + "/sounds",
				data_path + "/sounds/sounds.json"
			);
		}
		LoadMap(
			file_path,
			save_path.empty() && !mainmenu
		);
	}else{
		fadeTo = file_path;
	}
	// Clear the save path.
	save_path = "";
}

// Load a map or execute a command. This function is triggered by many
// different causes.
void LoadMap( std::string file_path, bool autosave ){
	// In-map warping is achieved by splitting the string at '#'.
	size_t pound_idx = file_path.find_first_of( '#' );
	if( pound_idx != std::string::npos ){
		std::string s1 = file_path.substr( 0, pound_idx );
		std::string s2 = file_path.substr( pound_idx + 1 );
		if( s1.length() > 0 && s2.length() > 0 ){
			LoadMap( s1, false );
			LoadMap( s2, autosave );
		}
		return;
	}
	// Unpause if there is nothing mandating a paused game.
	if( !recipes_display && !trading_display && !settings_display
		&& !character_select_display ){
		paused = false;
	}
	// Haptic controller rumble at <intensity> for <milliseconds>.
	if( file_path.length() >= 10
		&& file_path.substr( 0, 7 ) == "RUMBLE " ){
		// Split the parameters.
		std::string params = file_path.substr( 7 );
		size_t space_at = params.find_first_of( ' ' );
		if( space_at != std::string::npos ){
			// Two parameters are present.
			fgl::hapticRumble(
				(float)world.safeStold( params.substr( 0, space_at ) ),
				(unsigned int)world.safeStold( params.substr( space_at + 1 ) )
			);
		}
		if( autosave ){ // TODO: Why save here? When does this trigger?
			SaveGame( 1 );
		}
		return;
	}
	// Sound specifications. These should be loaded in tandem with a
	// physical location.
	if( file_path.length() >= 8
		&& file_path.substr( 0, 7 ) == "SOUNDS " ){
		SoundSpecLoad(
			data_path + "/sounds",
			data_path + "/sounds/" + file_path.substr( 7 )
		);
		if( autosave ){
			SaveGame( 1 );
		}
		return;
	}
	// Play a sound once. Overlapping may occur.
	if( file_path.length() >= 11
		&& file_path.substr( 0, 10 ) == "PLAYSOUND " ){
		csPlaySound( file_path.substr( 10 ), false );
		if( autosave ){ // TODO: Why save here?
			SaveGame( 1 );
		}
		return;
	}
	// Loop a sound infinitely. Overlapping will not occur.
	if( file_path.length() >= 11
		&& file_path.substr( 0, 10 ) == "LOOPSOUND " ){
		if( !csSoundIsPlaying( file_path.substr( 10 ) ) ){
			csPlaySound( file_path.substr( 10 ), true );
		}
		if( autosave ){ // TODO: Why save here?
			SaveGame( 1 );
		}
		return;
	}
	// Stop all playing sounds.
	if( file_path.length() == 10 && file_path == "STOPSOUNDS" ){
		cs_stop_all_sounds( csctx );
		if( autosave ){ // TODO: Why save here?
			SaveGame( 1 );
		}
		return;
	}
	// Unlock an achievement.
	if( file_path.length() >= 9
		&& file_path.substr( 0, 8 ) == "ACHIEVE " ){
		if( autosave ){
			SaveGame( 1 );
		}
		// Saving is synchronous, so achieve after to avoid overlay lag.
		Achieve( file_path.substr( 8 ) );
		return;
	}
	// Display some dialogue. NOT FOR USE INSIDE DIALOGUE SCRIPTS.
	if( file_path.length() >= 9
		&& file_path.substr( 0, 9 ) == "DIALOGUE " ){
		vnDataUpload();
		convo.go( file_path.substr( 9 ) );
		vnDataDownload( data_path );
		return;
	}
	// Display a notification popup.
	if( file_path.length() >= 7
		&& file_path.substr( 0, 7 ) == "NOTIFY " ){
		Notify( &tex_notification, file_path.substr( 7 ), "" );
		return;
	}
	// Make it rain with a percent probability. (Single-player only.)
	if( file_path.length() >= 6
		&& file_path.substr( 0, 5 ) == "RAIN " ){
		if( !multiplayer && world.safeStold( file_path.substr( 5 ) )
			> ( std::rand() % 100 ) ){
			raining = true;
			if( !csSoundIsPlaying( "ambient_rain" ) )
				csPlaySound( "ambient_rain", true );
		}
		if( autosave ){
			SaveGame( 1 );
		}
		return;
	}
	// The rest of this function deals with actually changing the
	// player's location.
	gibs_on_floor.clear();
	raining = false; // Always stop raining when changing locations.
	trading_display = false; // Hack to prevent erroneous trading.
	// Last zone cleanup.
	if( last_zone >= 0 && (size_t)last_zone < world.entities.size() ){
		std::string &type = world.entities[last_zone].type;
		// Make sure the indicated entity is still a zone.
		if( type.length() >= 4 && type.substr( 0, 4 ) == "zone" ){
			// Clear the zone.
			world.clearZone( last_zone );
		}
	}
	bool is_warp =
		file_path.length() >= 4 && file_path.substr( 0, 4 ) == "WARP";
	#ifndef __LIGHT__
		// Dungeons.
		if( dungeon.ready && !is_warp )
			dungeon.unloadMap();
	#endif
	if( file_path.length() >= 9
		&& file_path.substr( 0, 8 ) == "DUNGEON " ){
		#ifdef __LIGHT__
			Quit();
		#else
			convo.setVariable( "mainmenu", 0 );
			mainmenu = 0;
			dungeon.loadMap(
				data_path + "/dungeons",
				data_path + "/dungeons/" + file_path.substr( 8 )
			);
			if( autosave ){
				SaveGame( 1 );
			}
			// TODO: Save more data and maybe unload the tile map.
		#endif
		return;
	}
	// Intra-map warping.
	if( is_warp ){
		// Check if a map is already loaded.
		if( world.followEntity >= 0 && world.entities.size() >= 1 ){
			auto &player = world.entities[world.followEntity];
			bool can_warp = false;
			for( auto &ent : world.entities ){
				if( ent.type == file_path ){
					player.x = ent.x;
					player.y = ent.y;
					can_warp = true;
					break;
				}
			}
			if( !can_warp ){
				fprintf( stderr, "Failed to warp to %s\n", file_path.c_str() );
				Quit();
			}
			if( autosave ){
				SaveGame( 1 );
			}
		}
		return;
	}
	// On the main menu, never load the same 2D map currently loaded.
	if( convo.getVariable( "mainmenu" )
		&& file_path.length() > 0 && file_path == world.mapFile ){
		return;
	}
	world.mapFps = 6.0;
	if( file_path.length() > 0 ){
		// Load a 2D map.
		convo.setVariable( "mainmenu", 0 );
		mainmenu = 0;
		// Former site of non-cancelled player rest state bug.
		// Loading it twice doesn't do anything. Why do old entities go on top of new tiles?
		world.loadMap( data_path, file_path );
	}else{
		world.unloadMap();
	}
	// Glue to connect the game to the fworld loader.
	plants = flora::GetPlants( &world );
	civilians = civilian::GetCivilians( &world );
	fighters = fighter::GetFighters( &world );
	//wolves = wolf::GetWolves( &world, seed_dist( mt ) );
	// Player entity synchronization.
	// Reset item selections. Apply health and money.
	int inventorySize = 0;
	if( world.followEntity >= 0 && world.entities.size() >= 1 ){
		auto &player = world.entities[world.followEntity];
		inventorySize = player.inventory.size();
		// Only override the newly loaded world entity variables when VN
		// health is non-zero, which implies the game has already
		// started.
		// Only override if not loading from a saved game.
		if( health != 0 && save_path.empty() ){
			player.health = health;
			player.money = money;
		}
		// Synchronize variables.
		vnDataUpload();
	}
	item_selected.assign( inventorySize, false );
	somethingSelected = false;
	// Update the special item list.
	UpdateSpecialItems();
	if( autosave ){
		SaveGame( 1 );
	}
	// Map loading necessitates a tick to avoid a visual jump when
	// systems kick in.
	if(
	#ifndef __LIGHT__
		dungeon.ready ||
	#endif
	world.map.size() > 0 ){
		Tick();
	}
}

// Return the entity that will be spawned when the specified map is loaded.
fworld::Entity GetMapSpawn( std::string dataPath, std::string filePath ){
	// Load the map JSON file.
	FILE* file = FileOpen( filePath.c_str(), "rb" );
	if( !file ) return {};
	std::string text = "";
	char buf[4096];
	while( size_t len = fread( buf, 1, sizeof( buf ), file ) ){
		text += std::string( buf, len );
	}
	fclose( file );

	// Parse the map JSON file. TODO: Report inability to read -nan without JSON_READER_ALL!
	auto allocatedDocument = jsonAllocateDocumentEx( text.c_str(), text.size(), JSON_READER_ALL );

	if( allocatedDocument.document.error.type != JSON_OK ){
		fprintf( stderr, "JSON error: %s at line %lu:%lu of %s\n\n",
			jsonGetErrorString( allocatedDocument.document.error.type ),
			(long unsigned int)allocatedDocument.document.error.line,
			(long unsigned int)allocatedDocument.document.error.column,
			filePath.c_str() );
		jsonFreeDocument( &allocatedDocument );
		return {};
	}

	auto &info = allocatedDocument.document.root; // Map root.

	// Loop through map layers.
	for( auto &l : info["layers"].getArray() ){
		// Loop through layer's objects.
		for( auto &o : l["objects"].getArray() ){
			JsonStringView type = o["type"].getString();
			if( std::string( type.data, type.size ) == "spawn" ){
				fworld::Entity ent = world.parseEntity( o, dataPath );
				jsonFreeDocument( &allocatedDocument );
				return ent;
			}
		}
	}

	jsonFreeDocument( &allocatedDocument );
	return {};
}

// Bring up a menu that pauses the game.
void PauseMenu( std::string fileName ){
	if( fgl::mouseTrapped )
		fgl::trapMouse( false );
	if( !fgl::controller )
		show_cursor = true;
	paused = true;
	vnDataUpload();
	convo.go( fileName );
	vnDataDownload( data_path );
}

// Bring up a settings menu.
void OpenSettings( std::string category ){
	if( convo.screen.bg.empty() && world.map.empty()
	#ifndef __LIGHT__
		&& !dungeon.ready
	#endif
	){
		// Nothing to display, so swap in a black background.
		convo.screen.bg = "black";
	}
	choiceIndex = -1;
	paused = true;
	settings_display = true;
	settings_category = category;
	convo.screen.id = "SETTINGS";
	convo.screen.exec = {};
	convo.screen.caption = "";
	convo.screen.lines = { "OK!" };
	convo.screen.ids = { "CANCEL" };
}

// Bring up the character select screen.
void OpenCharacterSelect( std::string next_screen ){
	if( convo.screen.bg.empty() && world.map.empty()
	#ifndef __LIGHT__
		&& !dungeon.ready
	#endif
	){
		// Nothing to display, so swap in a black background.
		convo.screen.bg = "black";
	}
	// TODO.
	character_selected = "";
	character_next_screen = next_screen;
	choiceIndex = show_cursor ? -1 : 0;
	paused = true;
	character_select_display = true;
	/*
	settings_category = category;
	convo.screen.id = "SETTINGS";
	convo.screen.exec = {};
	convo.screen.caption = "";
	convo.screen.lines = { "OK!" };
	convo.screen.ids = { "CANCEL" };
	*/
}

// Bring up a text input prompt.
void TextInputPrompt( std::string *target, std::string prompt ){
	if( fgl::mouseTrapped )
		fgl::trapMouse( false );
	if( !fgl::controller )
		show_cursor = true;
	inputTarget = target;
	inputPrompt = prompt;
	fgl::textInputString = *inputTarget;
	#ifdef __STEAM__
		if( use_steam && fgl::controller ){
			SteamUtils()->ShowGamepadTextInput(
				k_EGamepadTextInputModeNormal,
				k_EGamepadTextInputLineModeSingleLine,
				prompt.c_str(),
				255,
				target->c_str()
			);
		}
	#endif
}

// Return a string that is safe to store in and retrieve from a JSON file.
std::string StringSanitize( std::string str ){
	if( str.length() > 255 )
		str = str.substr( 0, 255 );
	for( auto &c : str ){
		if( c == '"' ){
			c = '\'';
		}else if( c == '\\' ){
			c = '/';
		}else if( c == '\0' || c == '\a' || c == '\b' || c == '\e'
			|| c == '\f' || c == '\n' || c == '\r' || c == '\t'
			|| c == '\v' ){
			c = ' ';
		}
	}
	return str;
}

int GetButtonState( std::string *button ){
	return ( !button || button->empty() ) ? 0 : fgl::charKey( (*button)[0] );
}

// The player interacts with an entity in the world using a
// context-sensitive button. The entities are not necessarily touching.
// Returns true if an interaction happens, otherwise false.
bool WorldInteract( int ent_index ){
	auto &player = world.entities[world.followEntity];
	if( player.task == fworld::TASK_SLEEP )
		return false;
	auto &ent = world.entities[ent_index];
	if( somethingSelected
		|| ent.type == "flora"
		|| ent.type == "pickup"
		|| ( ent.task == fworld::TASK_NONE && ent.type == "regrow" ) ){
		player.task = fworld::TASK_BUMP;
		bumpIndex = ent_index;
		// Look at the entity.
		world.entityLookAt( player, ent );
		circleX = ent.x;
		circleY = ent.y;
		long long
			entTileX = ent.x + 0.5,
			entTileY = ent.y + 0.5;
		// Solve a path to the target.
		world.solveEntityPath( player, entTileX, entTileY, false );
		if( somethingSelected && ent_index == world.followEntity ){
			// Use the item(s) on the player.
			bool deselecting = false;
			fworld::Item *eat_item = nullptr;
			// Iterate in reverse order to safely remove consumed items.
			for( int i = (int)item_selected.size() - 1; i >= 0; i-- ){
				if( item_selected[i] ){
					// Get a reference to the selected item.
					fworld::Item &item =
						world.items[player.inventory[i].first];
					if( item.flavor > 0 ){
						// Remove the item from the player's inventory.
						double &count = player.inventory[i].second;
						count--;
						if( count == 0.0 ){
							// Remove the item from the player's inventory.
							player.inventory.erase( player.inventory.begin() + i );
							deselecting = true;
						}
						// Eat the item.
						Eat( player, item );
						eat_item = &item;
					}else{
						// Non-edible; deselect.
						deselecting = true;
					}
				}
			}
			// Deselect all if any items were completely consumed.
			if( deselecting ){
				item_selected.assign( player.inventory.size(), false );
				somethingSelected = false;
			}
			// Play the eat sound and run script if the player is eating.
			if( eat_item ){
				csPlaySound( "eat", false );
				if( eat_item->script.length() > 0 )
					Warp( eat_item->script );
			}
		}else if( somethingSelected && ent.bribeItems.size() > 0 ){
			// Attempt to bribe the entity.
			// Signal that the player is approaching an entity to "talk".
			player.task = fworld::TASK_TALK;
			// Use an Inventory because it has `contains`.
			fworld::Inventory selectedItems = {};
			for( size_t i = 0; i < item_selected.size(); i++ ){
				if( item_selected[i] ){
					selectedItems.push_back( { player.inventory[i].first, -1.0 } );
				}
			}
			// Check if the player is using the required items.
			bool correctItems = true;
			for( auto &item : ent.bribeItems ){
				if( !selectedItems.contains( item.first )
					|| player.inventory.get( item.first ) < item.second ){
					correctItems = false;
					break;
				}
			}
			if( correctItems ){
				// Remove the items from the player's inventory.
				for( auto &item : ent.bribeItems ){
					player.inventory.get( item.first ) -= item.second;
				}
				world.deleteInventoryZeroes( player.inventory );
				// The entity will take no further bribes.
				world.transferInventory(
					ent.bribeItems,
					ent.inventory,
					inventory_slots
				);
				ent.dialogue = ent.bribeNewDialogue;
				// Make sentries move out of the way.
				if( ent.type == "sentry" ){
					ent.type = "civilian";
					ent.staticCollisions = false;
					civilians = civilian::GetCivilians( &world );
					world.recalculateMapEntities();
				}
				// Swap the sprite if valid and not fightable.
				if( ent.bribeSprite.success && !ent.meleeSprite.success )
					ent.sprite = ent.bribeSprite;
			}
			// Bribe feedback.
			std::string &cap = correctItems ? ent.bribeSuccess : ent.bribeFail;
			if( cap.length() > 0 && cap[0] == '#' ){
				// Execute the script.
				Warp( cap );
			}else if( cap.length() > 0 ){
				// Display the caption.
				dialogue::Screen exit_screen = {};
				exit_screen.id = "exit";
				convo.setScreen( exit_screen );
				convo.screen = {
					"",
					"",
					"",
					{},
					language == "zh" ? EnToZh( cap ) : cap,
					{},
					{ "exit" }
				};
			}
			// Deselect all.
			item_selected.assign( player.inventory.size(), false );
			somethingSelected = false;
		}else if( ent.type == "civilian" || ent.type == "bird"
			|| ent.type == "fighter" ){
			// Civilians and fighters aren't crafting ingredients; cancel.
			player.task = fworld::TASK_NONE;
			circleX = DBL_INF;
			circleY = DBL_INF;
			// Deselect all (again).
			// TODO: Look into restructuring to be less repetitive.
			item_selected.assign( player.inventory.size(), false );
			somethingSelected = false;
			return false;
		}
	}else if( ent_index == world.followEntity ){
		// Cancel everything.
		player.task = fworld::TASK_NONE;
		circleX = DBL_INF;
		circleY = DBL_INF;
		return false;
	}else if( ent.dialogue.length() > 0
			&& ent.task != fworld::TASK_SLEEP
			&& ent.type != "fighter" ){
		player.task = fworld::TASK_TALK;
		// Look at the entity.
		world.entityLookAt( player, ent );
		circleX = ent.x;
		circleY = ent.y;
		// Solve a path to the target.
		world.solveEntityPath( player, (long long)( ent.x + 0.5 ), (long long)( ent.y + 0.5 ), false );
		//addAchievement( "Conversationalist" );
		// Activate dialogue.
		vnDataUpload();
		convo.go( ent.dialogue );
		vnDataDownload( data_path );
	}else if( ( ent.task == fworld::TASK_SLEEP && ent.type == "corpse" )
		|| ent.type == "crate" ){
		// Look at the entity.
		world.entityLookAt( player, ent );
		circleX = ent.x;
		circleY = ent.y;
		// Solve a path to the target.
		world.solveEntityPath( player, (long long)( ent.x + 0.5 ), (long long)( ent.y + 0.5 ), false );
		// Loot the corpse or crate.
		BeginTrading();
	}else{
		// Nothing to do.
		return false;
	}
	// Make sure the civilian acknowledges the player if the player is
	// talking. This little nudge is needed because civilian AI is only
	// updated every tick and we don't want the civilian to walk away.
	if( player.task == fworld::TASK_TALK
		&& ent.type == "civilian" ){
		world.entityLookAt( ent, player );
		ent.path.clear();
		ent.walking = false;
		ent.task = fworld::TASK_NONE;
	}
	// Default case: An interaction happens.
	return true;
}

int GetTradingTotal(){
	// Calculate total.
	int total = 0;
	size_t i = 0;
	if( world.followEntity >= 0 && world.entities.size() >= 1 ){
		auto &player = world.entities[world.followEntity];
		for( auto &item : player.inventory ){
			if( item_selected[i] ){
				total += GetItemPayPrice(
					*trading_entity,
					world.items[item.first] ) * item.second;
			}
			i++;
		}
	}
	i = 0;
	for( auto &item : trading_entity->inventory ){
		if( partner_item_selected.size() > i
				&& partner_item_selected[i] )
			total -= world.items[item.first].price * item.second;
		i++;
	}
	return total;
}

// Trade with whatever is standing in front of or around the player.
// This should be called when the player initiates a TRADE action and
// when the player reaches the trading target.
void BeginTrading(){
	// Find the entity the player is trading with.
	trading_entity = nullptr;
	if( world.followEntity >= 0 && world.entities.size() > 0 ){
		auto &player = world.entities[world.followEntity];
		player.task = fworld::TASK_TALK;
		// Trading is technically turned on, but not active until
		// trading_entity is non-null.
		trading_display = true;
		for( auto &ent : world.entities ){
			if( ent.dialogue.empty() && ent.type != "corpse"
				&& ent.type != "crate" ){
				continue;
			}
			if( world.entitiesBumping( player, ent ) ){
				trading_entity = &ent;
				break;
			}
		}
		// If the player is facing an entity with dialogue, or a corpse
		// or crate, that overrides any other entity, as it is more
		// precise than distance.
		if( world.facingEntity >= 0 && (
				world.entities[world.facingEntity].dialogue.length() > 0
				|| world.entities[world.facingEntity].type == "corpse"
				|| world.entities[world.facingEntity].type == "crate" ) ){
			trading_entity = &world.entities[world.facingEntity];
		}
		if( trading_entity ){
			// Touching trading_entity.
			if( fgl::mouseTrapped )
				fgl::trapMouse( false );
			if( !fgl::controller )
				show_cursor = true;
			paused = true;
			partner_item_selected.assign(
				trading_entity->inventory.size(),
				false
			);
			// Clear the player's path.
			if( world.followEntity >= 0 && world.entities.size() > 0 )
				world.entities[world.followEntity].path.clear();
			vnDataUpload();
			convo.go( "trade.json" );
			vnDataDownload( data_path );
		}else if( world.followEntity < 0
			|| world.entities[world.followEntity].path.empty() ){
			// Not touching trading_entity or homing in. Cancel trade.
			trading_display = false;
			// TODO: Use localized text.
			vnDataUpload();
			dialogue::Screen exit_screen = {};
			exit_screen.id = "exit";
			convo.setScreen( exit_screen );
			dialogue::Screen getcloser_screen = {};
			getcloser_screen.id = "GETCLOSER";
			getcloser_screen.caption = "[Get closer.]";
			getcloser_screen.ids.push_back( "exit" );
			convo.setScreen( getcloser_screen );
			convo.go( "GETCLOSER" );
			vnDataDownload( data_path );
		}
	}
}

void FinishTrading(){
	// So much data is read and wrtitten here that various errors must
	// be handled.
	if( world.followEntity < 0 || world.entities.empty()
		|| !trading_display || !trading_entity ){
		// Function is called under inopportune circumstances.
		return;
	}
	auto &player = world.entities[world.followEntity];
	bool looting = ( trading_entity->task == fworld::TASK_SLEEP
		&& trading_entity->type == "corpse" )
		|| trading_entity->type == "crate";
	int total = looting ? 0 : GetTradingTotal();
	// Check if looting or money is sufficient on both sides.
	bool success = looting
		? true
		: total <= trading_entity->money && total >= player.money * -1;
	// Check if both inventories have space for the trade.
	fworld::Inventory items_player = {}, items_partner = {};
	if( success ){
		size_t i = 0;
		for( auto &item : player.inventory ){
			if( item_selected[i] )
				items_player.push_back( item );
			i++;
		}
		i = 0;
		for( auto &item : trading_entity->inventory ){
			if( partner_item_selected.size() > i
					&& partner_item_selected[i] )
				items_partner.push_back( item );
			i++;
		}
		/*
		success =
			std::abs( items_player.size() - items_partner.size() )
			<= inventory_slots;
		*/
		// TODO: This disallows some trades that would be possible.
		success =
			world.inventoryCanTakeAll( player.inventory, items_partner, inventory_slots )
			&& world.inventoryCanTakeAll( trading_entity->inventory, items_player, inventory_slots );
	}
	// At this point, we should be certain that the trade is possible.
	if( success ){
		// Swap items.
		// Remove items from their owners' inventories.
		for( auto &item : items_player ){
			player.inventory.get( item.first ) -= item.second;
		}
		for( auto &item : items_partner ){
			trading_entity->inventory.get( item.first ) -= item.second;
		}
		// Place the items in their new inventories.
		world.transferInventory( items_partner, player.inventory, inventory_slots );
		if( looting ){
			world.transferInventory( items_player, trading_entity->inventory, inventory_slots );
		}else{
			for( auto &item : items_player ){
				if( world.items[item.first].flavor > 0 ){
					// Make the entity eat the stack.
					for( int i = 0; i < item.second; i++ ){
						Eat( *trading_entity, world.items[item.first] );
					}
				}else{
					// Put the item in the entity's inventory.
					fworld::Inventory inv;
					inv.set( item.first, item.second );
					world.transferInventory( inv, trading_entity->inventory, inventory_slots );
				}
			}
		}
		// Prevent a zero-item bug with the trading entity.
		world.deleteInventoryZeroes( trading_entity->inventory );
		// Transfer money.
		player.money += total;
		money = player.money;
		trading_entity->money -= total;
		vnDataUpload();
		csPlaySound( "trade_success", false );
		// Deselect all.
		item_selected.assign( player.inventory.size(), false );
		somethingSelected = false;
		if( trading_entity->health <= 0 && !looting )
			KillEntity( *trading_entity );
	}else{
		// Reload the trading dialogue.
		vnDataUpload();
		convo.go( "trade.json" );
		vnDataDownload( data_path );
		csPlaySound( "trade_fail", false );
	}
}

// Open a 16x8 sequencer.
void BeginSequencer( std::string param ){
	sequencer_display = true;
	if( fgl::mouseTrapped )
		fgl::trapMouse( false );
	if( !fgl::controller )
		show_cursor = true;
	// Clear the player's path.
	if( world.followEntity >= 0 && world.entities.size() > 0 )
		world.entities[world.followEntity].path.clear();
	vnDataUpload();
	convo.go( "sequencer.json" );
	vnDataDownload( data_path );
	cs_stop_all_sounds( csctx );
	size_t semi_at = param.find_first_of( ';' );
	std::string sequencer_sample;
	if( semi_at == std::string::npos ){
		sequencer_sample = param;
		sequencer_name = "";
	}else{
		sequencer_sample = param.substr( 0, semi_at );
		sequencer_name = param.substr( semi_at + 1 );
	}
	csPlaySound( sequencer_sample, false );
	fseq::Test( csctx );
	// TODO.
}

void FinishSequencer(){
	sequencer_display = false;
	sequencer_name = "";
	// TODO.
}

void BeginCake(){
	cake_display = true;
	// TODO.
}

void FinishCake(){
	cake_display = false;
	// TODO.
}

// Variables used by Tick, TurboSleep, and Render.
double tick_duration = 0.3, tick_timer = 0.0, timeout_timer = 0.0,
	rich_presence_duration = 15.0, rich_presence_timer = 0.0,
	inventory_service_duration = 65.0, inventory_service_timer = 0.0;

// Called at a fixed time step to execute actions every few frames.
void Tick(){
	// Poll for special items.
	PollSpecialItems();
	// Get the zone the player is in.
	int zone = world.getEntityZone( world.followEntity );
	// Single-player world updates.
	if( mainmenu || !multiplayer ){
		// Set the zone seed to a (pseudo-)random number.
		zone_seed = seed_dist( mt );
		// Only update plant growth when not in a zone.
		if( zone == -1 ){
			double growth_rate = tick_duration / plant_grow_period;
			flora::Grow( &plants, &world, seed_dist( mt ), growth_rate );
		}
		civilian::AI( &civilians, &world, seed_dist( mt ), tick_duration );
		fighter::AI( &fighters, &world, seed_dist( mt ), tick_duration, HitCallback );
		//wolf::AI( &wolves, &world, seed_dist( mt ), tick_duration );
		// Handle `regrow` behavior and `campfire` burnouts.
		for( size_t i = 0; i < world.entities.size(); i++ ){
			auto &ent = world.entities[i];
			if( ent.task == fworld::TASK_SLEEP && ent.type == "regrow" ){
				if( ent.stun <= 0.0 ){
					// Restore the entity to a non-sleeping state.
					ent.task = fworld::TASK_NONE;
				}
			}else if( ent.age > campfire_duration && ent.type == "campfire" ){
				if( ent.inventory.size() > 0 ){
					// Replace the campfire with its first item.
					AddEntity(
						world.items[ent.inventory[0].first].entity,
						ent.x,
						ent.y,
						i
					);
				}else{
					// Just remove the campfire.
					world.removeEntity( i );
				}
			}
		}
		return;
	}
	// Multiplayer.
	// Set the zone seed to the build in which it was introduced.
	zone_seed = 28;
	timeout_timer += tick_duration;
	if( timeout_timer > 5.0 ){
		timeout_timer = 0.0;
		Notify(
			&tex_net_notification,
			"Network Error",
			"Connection lost"
		);
		// Return to the main menu.
		settings_display = false;
		inputTarget = nullptr;
		convo.go( "init.json" );
		return;
	}
	auto &player = world.entities[world.followEntity];
	if( world_time > 0.0 ){
		// Synchronize the player's time of day with the server.
		double last_age = player.age;
		player.age =
			std::floor( player.age / day_duration ) * day_duration
			+ std::fmod( world_time, day_duration );
		// Advance the day if the new age is earlier than the last age,
		// beyond the margin of error.
		if( last_age - player.age > 1.0 ) player.age += day_duration;
	}
	auto player_state =
		+ "{\"itemName\":\"" + StringSanitize( player.itemName ) + "\","
		+ "\"name\":\"" + StringSanitize( player.name ) + "\","
		+ "\"x\":" + std::to_string( player.x ) + ","
		+ "\"y\":" + std::to_string( player.y ) + ","
		+ "\"age\":" + std::to_string( player.age ) + ","
		+ "\"frame\":" + std::to_string( player.frame ) + ","
		+ "\"stun\":" + std::to_string( player.stun ) + ","
		+ "\"meleeDamage\":" + std::to_string( player.meleeDamage ) + ","
		+ "\"task\":" + std::to_string( player.task ) + ","
		+ "\"direction\":" + std::to_string( player.direction ) + ","
		+ "\"walking\":" + std::to_string( player.walking ) + ","
		+ "\"chat_message\":\"" + ws_chat_message + "\"}";
	ws_chat_message = "";
	if( wsSendString( player_state ) || mainmenu ) return;
	// If appropriate, set the first message to be sent.
	if( !inputTarget && player.name.length() > 0 )
		ws_chat_message = "/me joined";
	std::string test_server =
		settings.sections.get( "Misc" ).get( "test_server" );
	wsConnect( test_server, []( std::string message ){
		ws_entities.clear();
		// Parse the JSON.
		auto alloc_doc =
			jsonAllocateDocument( message.c_str(), message.size(), JSON_READER_TRAILING_COMMA );
		if( alloc_doc.document.error.type == JSON_OK ){
			// Reset the timeout timer.
			timeout_timer = 0.0;
			auto &r = alloc_doc.document.root;
			unsigned long long id = r["id"].getUInt64();
			world_time = r["world_time"].getDouble();
			auto ents = r["entities"].getArray();
			for( auto &e : ents ){
				unsigned long long eid = e["id"].getUInt64();
				// Do not process a duplicate player entity.
				if( eid == id ) continue;
				// Get the item name.
				auto s = e["itemName"].getString();
				// Add the entity to the list.
				if( s.size > 0 ){
					std::string itemName =
						StringSanitize( std::string( s.data, s.size ) );
					ws_entities.push_back( world.items[itemName].entity );
				}else{
					ws_entities.resize( ws_entities.size() + 1 );
				}
				auto &entity = ws_entities.back();
				s = e["name"].getString();
				entity.name = StringSanitize( std::string( s.data, s.size ) );
				entity.age = e["age"].getDouble();
				entity.frame = e["frame"].getDouble();
				entity.stun = e["stun"].getDouble();
				entity.meleeDamage = e["meleeDamage"].getInt();
				// Retrieve locally stored state for the entity ID.
				auto &ps = ws_peer_states[eid];
				entity.task = e["task"].getInt();
				// Network ticks can happen in the middle of melee
				// attacks. Starting on the first frame looks better.
				// However, this hack should not be used twice in a row.
				if( entity.task == fworld::TASK_MELEE
					&& ps.task != fworld::TASK_MELEE ){
					entity.frame = 0.0;
					entity.stun = entity.meleeRecovery;
				}
				ps.task = entity.task;
				entity.direction = e["direction"].getInt();
				entity.walking = e["walking"].getBool();
				entity.x = e["x"].getDouble();
				entity.y = e["y"].getDouble();
				entity.last_x = ps.last_x;
				entity.last_y = ps.last_y;
				ps.last_x = entity.x;
				ps.last_y = entity.y;
			}
			// Chat messages.
			auto messages = r["chat_messages"].getArray();
			for( auto &m : messages ){
				unsigned long long from_id = m["from_id"].getUInt64();
				auto s = m["message"].getString();
				if( s.size > 0 ){
					// FIFO.
					if( chat_buffer.size() >= chat_buffer_capacity ){
						chat_buffer.erase(
							chat_buffer.begin(),
							chat_buffer.begin() + chat_buffer_capacity
								- chat_buffer.size() + 1
						);
					}
					// Process the ": /me" out of the string.
					std::string str =
						StringSanitize( std::string( s.data, s.size ) );
					size_t sub_at = str.find( ": /me" );
					if( sub_at != std::string::npos )
						str.erase( sub_at, 5 );
					chat_buffer.push_back( str );
				}
			}
		}
		jsonFreeDocument( &alloc_doc );
	} );
}

void TurboSleep(){
	auto &player = world.entities[world.followEntity];
	if( player.health < max_health ){
		player.health++;
	}
	// If max_health is 100, TurboSleep advances time by 1% of sleep_duration.
	int iterations = std::round( sleep_duration / tick_duration / max_health );
	for( int i = 0; i < iterations; i++ ){
		Tick();
		world.simulate( tick_duration );
	}
}

void UpdateRichPresence(){
	#ifdef __STEAM__
		if( use_steam && !is_steam_china ){
			if( mainmenu ){
				// On main menu.
				SteamFriends()->SetRichPresence(
					"steam_display",
					"#Status_MainMenu"
				);
			}else{
				// In game.
				SteamFriends()->SetRichPresence(
					"day",
					std::to_string( day ).c_str()
				);
				SteamFriends()->SetRichPresence(
					"steam_display",
					multiplayer ? "#Status_Multiplayer" : "#Status_SinglePlayer"
				);
			}
		}
	#else
		// Other platforms' rich presence APIs go here.
	#endif
}

void UpdateSpecialItems(){
	#ifdef __STEAM__
		if( use_steam ){
			// Free the result of the last request if applicable.
			if( special_item_result ){
				SteamInventory()->DestroyResult( special_item_result );
				special_item_result = 0;
			}
			// Request a new list of items.
			SteamInventory()->GetAllItems( &special_item_result );
		}
	#else
		// Other platforms' special item APIs go here.
	#endif
}

void PollSpecialItems(){
	#ifdef __STEAM__
		if( use_steam && !mainmenu && !paused && special_item_result
			&& SteamInventory()->GetResultStatus( special_item_result ) == k_EResultOK ){
			std::vector<SteamItemDetails_t> vec_items;
			uint32 num_items = 0;
			if( SteamInventory()->GetResultItems( special_item_result, nullptr, &num_items ) ){
				vec_items.resize( num_items );
				SteamInventory()->GetResultItems( special_item_result, vec_items.data(), &num_items );
			}
			if( num_items > 0 ) special_items.clear();
			// Group like items.
			std::map<SteamItemDef_t,std::vector<SteamItemDetails_t>> item_groups;
			for( auto &item : vec_items ){
				// The vector may not be initialized yet.
				if( item_groups.find(item.m_iDefinition) == item_groups.end() )
					item_groups[item.m_iDefinition] = {};
				item_groups[item.m_iDefinition].push_back( item );
			}
			// Collapse groups into one stack per definition.
			for( auto &group : item_groups ){
				for( size_t i = 1; i < group.second.size(); i++ ){
					SteamInventoryResult_t transferResult;
					SteamInventory()->TransferItemQuantity(
						&transferResult,
						group.second[i].m_itemId,
						group.second[i].m_unQuantity,
						group.second[0].m_itemId
					);
					SteamInventory()->DestroyResult( transferResult );
					// Update counters to reflect the change.
					group.second[0].m_unQuantity +=
						group.second[i].m_unQuantity;
					group.second[i].m_unQuantity = 0;
				}
			}
			// Update item icons.
			for( auto &group : item_groups ){
				auto &item = group.second[0];
				// Quantity should never be 0, but just in case...
				if( item.m_unQuantity == 0 ) continue;
				fgl::Texture tex = world.getTexture(
					data_path,
					"special/" + std::to_string( item.m_iDefinition ) + ".png",
					false
				);
				if( tex.success ){
					// Add the item to the special inventory with its
					// localized name.
					char name[4096];
					uint32 size_out = sizeof(name);
					if( !SteamInventory()->GetItemDefinitionProperty( item.m_iDefinition, "name", name, &size_out ) )
						name[0] = '\0';
					special_items.push_back( {
						tex,
						std::string( name ),
						(int)item.m_iDefinition,
						(int)item.m_unQuantity
					} );
				}
			}
			// Free the result.
			SteamInventory()->DestroyResult( special_item_result );
			special_item_result = 0;
		}
	#else
		// Other platforms' special item polling APIs (whose?) go here.
	#endif
}

void UpdateInventoryService(){
	#ifdef __STEAM__
		if( use_steam && !paused && !mainmenu
			&& inventory_service_generators.size() > 0 ){
			SteamInventoryResult_t result;
			SteamInventory()->TriggerItemDrop(
				&result,
				(SteamItemDef_t)inventory_service_generators[0]
			);
			// Rotate the vector.
			std::rotate(
				inventory_service_generators.begin(),
				inventory_service_generators.begin() + 1,
				inventory_service_generators.end()
			);
			// Update the special item list.
			UpdateSpecialItems();
		}
	#else
		// Other platforms' inventory service APIs go here.
	#endif
}

void ModUpload( std::string title ){
	#ifdef __STEAM__
		if( use_steam ){
			SteamUGC()->CreateItem(
				app_steam_id,
				k_EWorkshopFileTypeCommunity
			);
		}
	#else
		// Other platforms' mod APIs go here.
	#endif
}

void Render(){
	double d = fgl::deltaTime();

	timeCount += d;
	frames++;
	if( timeCount >= 1.0 ){
		showFrames = frames;
		frames = 0;
		timeCount = std::fmod( timeCount, 1.0 );
	}

	// Game will slow down.
	if( d > 1.0 / minFps ){
		d = 1.0 / minFps;
	}

	// TODO: Decouple fworld from render loop.
	world.screenWidth = fgl::getDisplayWidth();
	world.screenHeight = fgl::getDisplayHeight();

	// Select screen scale depending on aspect ratio (orientation).
	if( world.screenWidth < world.screenHeight ){
		simScreenHeight2D = portraitScreenHeight2D;
		simScreenHeight3D = portraitScreenHeight3D;
	}else{
		simScreenHeight2D = landscapeScreenHeight2D;
		simScreenHeight3D = landscapeScreenHeight3D;
	}

	world.scale = world.screenHeight / (double)simScreenHeight3D;

	fgl::resizeFramebuffer(
		framebuffer,
		std::round( world.screenWidth ),
		std::round( world.screenHeight )
	);
	fgl::setFramebuffer( framebuffer );

	glDisable( GL_DEPTH_TEST );

	// Do every-few-frames stuff.
	if(
	#ifndef __LIGHT__
		dungeon.ready ||
	#endif
	world.map.size() > 0 ){
		if( !paused || ( multiplayer
			&& ( !inputTarget || inputTarget == &ws_chat_message ) ) ){
			tick_timer += d;
		}
		if( tick_timer >= tick_duration ){
			tick_timer = std::fmod( tick_timer, tick_duration );
			Tick();
		}
	}
	rich_presence_timer += d;
	if( rich_presence_timer >= rich_presence_duration ){
		rich_presence_timer =
			std::fmod( rich_presence_timer, rich_presence_duration );
		UpdateRichPresence();
	}
	inventory_service_timer += d;
	if( inventory_service_timer >= inventory_service_duration ){
		inventory_service_timer =
			std::fmod( inventory_service_timer, inventory_service_duration );
		UpdateInventoryService();
	}

	// Image animations.
	static double animation_timer;
	if( animation_layer > 0 ){
		animation_timer += d;
		if( animation_timer >= 1.0 / animation_fps ){
			animation_timer =
				std::fmod( animation_timer, 1.0 / animation_fps );
			std::string img_name =
				animation_layer == 1 ? convo.screen.bg : convo.screen.fg;
			size_t slash_at = img_name.find_first_of( '/' );
			if( slash_at == std::string::npos ){
				// Animations are enabled but no frame sequence
				// directory is specified.
				animation_layer = 0;
				animation_timer = 0.0;
			}else{
				// Make sure the next image is in the same directory.
				std::string img_dir = img_name.substr( 0, slash_at + 1 );
				if( animation_layer == 1 ){
					if( bgIndex + 1 < (int)backgrounds.size()
						&& backgrounds[bgIndex + 1].imgName.substr( 0, slash_at + 1 )
							== img_dir ){
						// Go to the next frame.
						bgIndex++;
						convo.screen.bg = backgrounds[bgIndex].imgName;
					}else if( backgrounds.size() > 0 && animation_loop ){
						// Go to the first frame.
						bgIndex = 0;
						convo.screen.bg = backgrounds[bgIndex].imgName;
					}else{
						// The backgrounds failed to load or no loop.
						animation_layer = 0;
						animation_timer = 0.0;
					}
				}else{
					if( fgIndex + 1 < (int)foregrounds.size()
						&& foregrounds[fgIndex + 1].imgName.substr( 0, slash_at + 1 )
							== img_dir ){
						// Go to the next frame.
						fgIndex++;
						convo.screen.fg = foregrounds[fgIndex].imgName;
					}else if( foregrounds.size() > 0 && animation_loop ){
						// Go to the first frame.
						fgIndex = 0;
						convo.screen.fg = foregrounds[fgIndex].imgName;
					}else{
						// The foregrounds failed to load or no loop.
						animation_layer = 0;
						animation_timer = 0.0;
					}
				}
			}
		}
	}else{
		animation_timer = 0.0;
	}

	// Calculate time of day and ambient light color.
	if( world.followEntity >= 0 && world.entities.size() >= 1 ){
		auto &player = world.entities[world.followEntity];
		day = player.age / day_duration + 1.0;
		size_t num_colors = sizeof(ambient_colors) / sizeof(fgl::Color);
		player_time =
			std::fmod( player.age, day_duration ) / day_duration;
		double color_pos = player_time * num_colors;
		int idx = color_pos;
		auto &color1 = ambient_colors[idx];
		auto &color2 = ambient_colors[( idx + 1 ) % num_colors];
		ambient_light = {
			(GLfloat)ease( color1.r, color2.r, color_pos - idx ),
			(GLfloat)ease( color1.g, color2.g, color_pos - idx ),
			(GLfloat)ease( color1.b, color2.b, color_pos - idx ),
			1.0f
		};
	}

	// Handle ambient sounds and music.
	cs_loaded_sound_t
		*ambient_day = sound_specs["ambient_day"].loaded_ptr,
		*ambient_night = sound_specs["ambient_night"].loaded_ptr,
		*ambient_zone = sound_specs["ambient_zone"].loaded_ptr,
		*ambient_rain = sound_specs["ambient_rain"].loaded_ptr;

	// TODO: Other fading sound transitions.
	static float ambient_zone_fade;
	static bool is_day;
	bool was_day = is_day;
	is_day = player_time > 0.25 && player_time < 0.75;
	bool in_zone = false; // Playing zone sounds; "in" so to speak.
	int zone = world.getEntityZone( world.followEntity );
	// Fade out the zone sound when not in the zone.
	if( zone >= 0 ){
		in_zone = true;
		ambient_zone_fade = 1.0f;
		csSetLoadedSoundVolume(
			ambient_zone,
			sound_specs["ambient_zone"].volume
		);
	}else if( ambient_day && ambient_day->playing_count < 1
		&& ambient_night && ambient_night->playing_count < 1 ){
		ambient_zone_fade -= 0.7 * d;
		if( ambient_zone_fade > 0.0f ){
			in_zone = true;
			csSetLoadedSoundVolume(
				ambient_zone,
				sound_specs["ambient_zone"].volume * ambient_zone_fade
			);
		}
	}

	cs_loaded_sound_t *target_sound =
		in_zone ? ambient_zone : ( is_day ? ambient_day : ambient_night );
	#ifndef __LIGHT__
		if( dungeon.ready
			&& sound_specs["ambient_underwater"].loaded_ptr
			&& PlayerUnderwater() ){
			// Player is underwater.
			// TODO: 3D "zones". Maybe in another game.
			csSwitchSound(
				target_sound,
				sound_specs["ambient_underwater"].loaded_ptr
			);
		}else
	#endif
	if(
	#ifndef __LIGHT__
		dungeon.ready ||
	#endif
	world.map.size() > 0 ){
		// Player is not underwater.
		if( target_sound ){
			// Switch underwater sounds to non-underwater sounds.
			csSwitchSound(
				sound_specs["ambient_underwater"].loaded_ptr,
				target_sound
			);
		}
		// Play non-underwater sounds if they are not currently playing.
		if( is_day != was_day ){
			// Transitioning from day to night or night to day.
			// Stop the rain.
			raining = false;
		}
		if( raining ){
			csStopLoadedSound( ambient_day );
			csStopLoadedSound( ambient_night );
		}else{
			csStopLoadedSound( ambient_rain );
		}
		if( in_zone && ambient_zone && ambient_zone->playing_count < 1 ){
			csStopLoadedSound( ambient_day );
			csStopLoadedSound( ambient_night );
			csPlaySound( "ambient_zone", true );
			// The player has just entered a zone, so regenerate it.
			RegenerateZone( zone, zone_seed );
		}else if( !raining && !in_zone && is_day && ambient_day && ambient_day->playing_count < 1 ){
			csStopLoadedSound( ambient_night );
			csStopLoadedSound( ambient_zone );
			csPlaySound( "ambient_day", true );
		}else if( !raining && !in_zone && !is_day && ambient_night && ambient_night->playing_count < 1 ){
			csStopLoadedSound( ambient_day );
			csStopLoadedSound( ambient_zone );
			csPlaySound( "ambient_night", true );
		}
	}else{
		// A game world is not loaded.
		csStopLoadedSound( ambient_day );
		csStopLoadedSound( ambient_night );
		csStopLoadedSound( ambient_zone );
		csStopLoadedSound( sound_specs["ambient_underwater"].loaded_ptr );
	}

	IniSection *kb = &settings.sections.get( "Keyboard" );

	if( inputTarget ){
		paused = true;
	}else{
		// Left joystick.
		stickX = fgl::leftStickX();
		stickY = fgl::leftStickY();

		// Hide the mouse cursor if the left joystick is moved past the
		// analog dead zone. Otherwise assign stick values to zero.
		if( std::sqrt( stickX * stickX + stickY * stickY ) > dead_zone ){
			show_cursor = false;
		}else{
			stickX = 0.0f;
			stickY = 0.0f;
		}

		std::string
			*k_up = kb ? &kb->get( "up" ) : nullptr,
			*k_down = kb ? &kb->get( "down" ) : nullptr,
			*k_left = kb ? &kb->get( "left" ) : nullptr,
			*k_right = kb ? &kb->get( "right" ) : nullptr;

		// Directional input for movement and menus.
		moveX =
			( fgl::rightKey() || GetButtonState( k_right ) || fgl::rightPad() ) -
			( fgl::leftKey()  || GetButtonState( k_left ) || fgl::leftPad() ) +
			stickX;
		moveY =
			( fgl::downKey() || GetButtonState( k_down ) || fgl::downPad() ) -
			( fgl::upKey()   || GetButtonState( k_up ) || fgl::upPad() ) +
			stickY;

		static int moveXLast;
		moveXInt = std::round( moveX * 1.4 );
		moveXDown = moveXInt == moveXLast ? 0 : moveXInt;
		moveXLast = moveXInt;

		static int moveYLast;
		moveYInt = std::round( moveY * 1.4 );
		moveYDown = moveYInt == moveYLast ? 0 : moveYInt;
		moveYLast = moveYInt;
	}

	std::string
		*k_melee = kb ? &kb->get( "melee" ) : nullptr,
		*k_place_item = kb ? &kb->get( "place_item" ) : nullptr,
		*k_recipes_menu = kb ? &kb->get( "recipes_menu" ) : nullptr,
		*k_rest = kb ? &kb->get( "rest" ) : nullptr;

	// Buttons for actions and menus.
	static bool actionButtonLast;
	actionButton = fgl::enterKey() || fgl::aButton();
	actionButtonDown = actionButton && !actionButtonLast;
	actionButtonLast = actionButton;

	static bool recipesButtonLast;
	recipesButton = GetButtonState( k_recipes_menu ) || fgl::bButton();
	recipesButtonDown = recipesButton && !recipesButtonLast;
	recipesButtonLast = recipesButton;

	static bool meleeButtonLast;
	meleeButton = GetButtonState( k_melee ) || fgl::xButton();
	meleeButtonDown = meleeButton && !meleeButtonLast;
	meleeButtonLast = meleeButton;

	static bool entityButtonLast;
	entityButton = GetButtonState( k_place_item ) || fgl::yButton();
	entityButtonDown = entityButton && !entityButtonLast;
	entityButtonLast = entityButton;

	static bool jumpButtonLast;
	jumpButton = fgl::spaceKey() || fgl::left1();
	jumpButtonDown = jumpButton && !jumpButtonLast;
	jumpButtonLast = jumpButton;

	static bool sleepButtonLast;
	sleepButton = GetButtonState( k_rest ) || fgl::selectButton();
	sleepButtonDown = sleepButton && !sleepButtonLast;
	sleepButtonLast = sleepButton;

	static bool pauseButtonLast;
	pauseButton = fgl::escapeKey() || fgl::startButton();
	pauseButtonDown = pauseButton && !pauseButtonLast;
	pauseButtonLast = pauseButton;

	static bool mouseLastLeft;
	mouseLeft = fgl::mouseButton( 1 ) || ( show_cursor && fgl::aButton() );
	mouseDownLeft = ( mouseLeft && !mouseLastLeft ) || fgl::touchStart;
	mouseLastLeft = mouseLeft;

	//static bool mouseLastRight;
	mouseRight = fgl::mouseButton( 2 );
	//mouseDownRight = mouseRight && !mouseLastRight;
	//mouseLastRight = mouseRight;

	// Switch to mouse mode when the mouse is used.
	if( mouseDownLeft || fgl::mouseMoveX || fgl::mouseMoveY ){
		show_cursor = true;
		if( !inputTarget ) choiceIndex = -1;
	}

	static double fadeLevel;
	GLfloat bloomFactor = 1.0f;
	bool fading =
		( ( fadeLevel > 0.0 && fadeLevel < 2.0 ) || fadeTo.length() > 0 )
		&& convo.screen.bg.empty();

	if( fading ){
		fadeLevel += 3.0 * d;
		bloomFactor = std::pow( 1.0 - fadeLevel, 4.0 );
		if( fadeLevel >= 1.0 && fadeTo.length() > 0 ){
			// Load the map.
			LoadMap( fadeTo, true );
			fadeTo = "";
		}
	}else{
		fadeLevel = 0.0;
	}

	#ifndef __LIGHT__
		if( dungeon.ready ){
			// Trap the mouse when there is no menu.
			if( convo.screen.ids.empty() ){
				if( !fgl::mouseTrapped ){
					fgl::trapMouse( true );
				}
				show_cursor = false;
			}
			dungeon.ambient_light = ambient_light;
			// Use the ambient light to color the background for the
			// "dungeon" view.
			fgl::Color color = {
				GLfloat(ambient_light.r * dungeon.fogBrightness),
				GLfloat(ambient_light.g * dungeon.fogBrightness),
				GLfloat(ambient_light.b * dungeon.fogBrightness),
				ambient_light.a
			};
			// Set fog color and density.
			fgl::setFog( {
				color.r,
				color.g,
				color.b,
				(GLfloat)dungeon.fogDensity
			} );
			// 3D mode. Clear only the depth buffer.
			glClear( GL_DEPTH_BUFFER_BIT );

			FirstPersonLoop( d );

			// Use the screen as the drawing surface.
			fgl::setFramebuffer();
			//fgl::cls( { 0.0f, 0.0f, 0.0f, 1.0f } );
			glClear( GL_DEPTH_BUFFER_BIT );
			fgl::setPipeline( bloomPipeline );

			// Fog is used for bloom parameters.
			GLfloat scale =
				dungeon.frameBuf->height / (double)simScreenHeight3D;
			fgl::setFog( {
				bloomSizeX * scale,
				bloomSizeY * scale,
				bloomThreshold * bloomFactor,
				( health < threshold_health && !fading && !mainmenu )
					? ( 0.0f ) : ( bloomAmount / bloomFactor )
			} );
			glDisable( GL_BLEND );
			fgl::drawFramebuffer( framebuffer );
			//fgl::drawFramebuffer( dungeon.shadowBuf );

			if( !fading )
				vnDraw( dungeon.frameBuf->width, dungeon.frameBuf->height );
		}else
	#endif
	if( fading ){
		if( fgl::mouseTrapped ){
			fgl::trapMouse( false );
		}
		// Black background for overworld view.
		fgl::cls( { 0.0f, 0.0f, 0.0f, 1.0f } );
		// Reset the circle position.
		circleX = DBL_INF;
		circleY = DBL_INF;
		// Reset blend mode.
		glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
		// Day/night tinting.
		fgl::setFog( ambient_light );
		// Draw the world.
		world.simulate( d );
		world.drawMap();
		SimulateParticles( d );
		DrawFloorParticles();
		if( multiplayer )
			world.drawEntities( ws_entities );
		else
			world.drawEntities();
		DrawParticles();
		// Use the screen as the drawing surface.
		fgl::setFramebuffer();
		glClear( GL_DEPTH_BUFFER_BIT );
		fgl::setPipeline( bloomPipeline );
		// Fog is used for bloom parameters.
		fgl::setFog( {
			GLfloat( bloomSizeX * world.scale ),
			GLfloat( bloomSizeY * world.scale ),
			bloomThreshold * bloomFactor,
			bloomAmount / bloomFactor
		} );
		fgl::drawFramebuffer( framebuffer );
		fgl::setPipeline( fgl::colorModPipeline );
		fgl::setFog( { 1.0f, 1.0f, 1.0f, 1.0f } );
	}else if( true
	#ifndef __LIGHT__
		&& !dungeon.ready
	#endif
	){
		if( fgl::mouseTrapped ){
			fgl::trapMouse( false );
		}
		// Black background for overworld view.
		fgl::cls( { 0.0f, 0.0f, 0.0f, 1.0f } );
		fadeLevel = 0.0;
		GameLoop( d );
	}

	if( paused && settings_display ){
		DrawSettings();
	}else if( paused && character_select_display ){
		DrawCharacterSelect();
	}

	// Draw the text input prompt.
	if( inputTarget && inputPrompt.length() > 0 ){
		double inputScale = world.screenHeight / 720.0;
		double pixelHeight = inputScale * font_vn.size * 1.333;
		fgl::setTextInput(
			0,
			world.screenHeight * 0.5,
			world.screenWidth,
			pixelHeight
		);
		// Text box.
		glDisable( GL_BLEND );
		drawImage(
			fgl::blankTexture,
			fgl::textInputRect.x,
			fgl::textInputRect.y,
			0.0,
			fgl::textInputRect.w / (double)fgl::blankTexture.width,
			fgl::textInputRect.h / (double)fgl::blankTexture.height
		);
		// Black text.
		glEnable( GL_BLEND );
		glBlendFunc( GL_ZERO, GL_ONE_MINUS_SRC_COLOR );
		fgl::drawText(
			fgl::textInputString + "_",
			font_vn,
			world.screenWidth * 0.5,
			fgl::textInputRect.y,
			inputScale,
			1
		);
		// White text.
		glBlendFunc( GL_ONE, GL_ONE );
		fgl::drawText(
			inputPrompt,
			font_vn,
			world.screenWidth * 0.5,
			world.screenHeight * 0.5 - pixelHeight,
			inputScale,
			1
		);
		// Submit the text by pressing the enter key or the A button on
		// a controller.
		if( ( fgl::textReturnStart || actionButtonDown )
			&& fgl::textInputString.length() > 0 ){
			// Do not propagate the button press.
			actionButtonDown = false;
			if( inputTarget == &ws_chat_message
				&& fgl::textInputString.length() > 0
				&& fgl::textInputString[0] == '/'
				&& ( fgl::textInputString.length() < 3
					|| fgl::textInputString.substr( 0, 3 ) != "/me" ) ){
				// Execute a command.
				ws_chat_message = "";
				vnDataUpload();
				std::string &str = fgl::textInputString;
				if( str.length() > 6
					&& str.substr( 0, 5 ) == "/call" ){
					double result =
						convo.getVariable( "CALLBACK " + str.substr( 6 ) );
					if( result ){
						Notify(
							&tex_notification,
							"Callback returned:",
							convo.stringifyNumber( result )
						);
					}
				}else if( str.length() > 5
					&& str.substr( 0, 4 ) == "/get" ){
					std::string key = str.substr( 5 ), result = "";
					if( key.back() == '$' ){
						key.resize( key.length() - 1 );
						if( key == "bg" ){
							result = convo.screen.bg;
						}else if( key == "fg" ){
							result = convo.screen.fg;
						}else if( key == "caption" ){
							result = convo.screen.caption;
						}else{
							auto it = convo.strings.find( key );
							if( it != convo.strings.end() )
								result = it->second;
						}
					}else if( key.back() == '@' ){
						key.resize( key.length() - 1 );
						if( key == "lines" ){
							result = convo.stringifyArray( convo.screen.lines );
						}else if( key == "ids" ){
							result = convo.stringifyArray( convo.screen.ids );
						}else{
							auto it = convo.arrays.find( key );
							if( it != convo.arrays.end() )
								result = convo.stringifyArray( it->second );
						}
					}else{
						result = convo.stringifyNumber( convo.getVariable( key ) );
					}
					Notify(
						&tex_notification,
						result,
						""
					);
				}else if( str.length() > 5
					&& str.substr( 0, 4 ) == "/set" ){
					std::string param = str.substr( 5 );
					size_t space_at = param.find_first_of( ' ' );
					if( space_at == std::string::npos
						|| space_at + 1 == param.length() ){
						Notify(
							&tex_notification,
							"Key value pair required",
							""
						);
					}else{
						std::string val_str =
							param.substr( space_at + 1 );
						char* p;
						double num = std::strtod( val_str.c_str(), &p );
						dialogue::Operation o = convo.getOperation(
							param.substr( 0, space_at ),
							num,
							""
						);
						// If defining a string or array, or value is
						// non-numeric, treat value as a string.
						if( o.op == '$' || o.op == '@' || *p )
							o.valueKey = val_str;
						convo.operate( o );
					}
				}else if( str.length() >= 5
					&& str.substr( 0, 5 ) == "/help" ){
					Notify(
						&tex_notification,
						"Available commands:",
						"/me /call /get /set /help"
					);
				}else{
					Notify( &tex_notification, "Syntax error:", str );
				}
				vnDataDownload( data_path );
			}else{
				// Send the message.
				*inputTarget = StringSanitize( fgl::textInputString );
				// Reverse-engineer the input target to determine if
				// this is the right time to send the "joined" message.
				if( multiplayer && world.followEntity >= 0
					&& (size_t)world.followEntity < world.entities.size()
					&& inputTarget == &world.entities[world.followEntity].name ){
					ws_chat_message = "/me joined";
				}
			}
			inputTarget = nullptr;
			fgl::setTextInput();
			if( !settings_display )
				paused = false;
		}else if( pauseButtonDown && inputTarget == &ws_chat_message ){
			// Cancel the chat message.
			// Do not propagate the button press.
			pauseButtonDown = false;
			ws_chat_message = "";
			inputTarget = nullptr;
			fgl::setTextInput();
			if( !settings_display )
				paused = false;
		}
	}

	DrawPopups( d );

	#ifdef __EMSCRIPTEN__
		// Synchronize audio playback.
		cs_mix( csctx );
	#endif

	#if !defined(SDL_MAJOR_VERSION) && !defined(__EMSCRIPTEN__)
		// Draw the game cursor where a hardware cursor is not used.
		if( show_cursor && !fading ){
			glEnable( GL_BLEND );
			glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
			drawImage( tex_cursor, fgl::mouseX, fgl::mouseY, 0.0f, 1.0f, 1.0f );
		}
	#elif !defined(__EMSCRIPTEN__)
		// Actively show or hide the hardware cursor.
		fgl::showMouse( show_cursor && !fading );
	#endif

	#ifdef __STEAM__
		if( use_steam ){
			// This keeps events in sync and does garbage collection.
			SteamAPI_RunCallbacks();
		}
	#endif

	fgl::sync();
}

void MakeGibs( double x, double y, linalg::vec<double,3> vel, int n ){
	uint32_t seed = std::rand();
	for( int i = 0; i < n; i++ ){
		// A slow and inefficient FIFO buffer.
		// TODO: Make it less slow and inefficient.
		if( gibs.size() == max_gibs ) gibs.erase( gibs.begin() );
		gibs.push_back( {
			true,
			proc::RandFloat( &seed ) * 0.2 - 0.1 + x,
			proc::RandFloat( &seed ) * 0.2 - 1.1 + y,
			gib_floor + 0.15,
			proc::RandFloat( &seed ) - 0.5 + vel.x,
			proc::RandFloat( &seed ) - 0.5 + vel.y,
			proc::RandFloat( &seed ) - 0.5 + vel.z
		} );
	}
}

void SimulateParticles( double d ){
	uint32_t seed = std::rand();
	//// Rain simulation. ////
	if( raining ){
		for( size_t i = raindrops.size(); i < max_raindrops; i++ ){
			// Cheat the perspective with positive Y velocity.
			raindrops.push_back( {
				true,
				proc::RandFloat( &seed ) * 20.0 - 10.0 + world.cameraX,
				proc::RandFloat( &seed ) * 20.0 - 10.0 + world.cameraY,
				proc::RandFloat( &seed ) * 8.0 - 7.5,
				proc::RandFloat( &seed ) - 0.5,
				5.26,
				-11.0 // On the high end of raindrop terminal velocity.
			} );
		}
	}
	for( auto &drop : raindrops ){
		drop.x += drop.vel_x * d;
		drop.y += drop.vel_y * d;
		drop.z += drop.vel_z * d;
		if( drop.z < -7.5 ){
			if( raining ){
				drop.x = proc::RandFloat( &seed ) * 20.0 - 10.0 + world.cameraX;
				drop.y = proc::RandFloat( &seed ) * 20.0 - 10.0 + world.cameraY;
				drop.z = 0.5;
			}else{
				drop.visible = false;
			}
		}
	}
	// Erase-remove idiom.
	raindrops.erase(
		std::remove_if(
			raindrops.begin(),
			raindrops.end(),
			[]( const Raindrop &drop ){ return !drop.visible; }
		),
		raindrops.end()
	);
	//// Gib simulation. ////
	for( auto &gib : gibs ){
		gib.vel_z -= 9.8 * d;
		gib.x += gib.vel_x * d;
		gib.y += gib.vel_y * d;
		gib.z += gib.vel_z * d;
		if( gib.z < gib_floor ){
			// Add the gib to the floor layer.
			gib.z = gib_floor;
			gib.vel_x = 0.0;
			gib.vel_y = 0.0;
			gib.vel_z = 0.0;
			if( gibs_on_floor.size() == max_gibs )
				gibs_on_floor.erase( gibs_on_floor.begin() );
			gibs_on_floor.push_back( gib );
			// This causes the gib to be deleted from the top layer.
			gib.visible = false;
		}
	}
	// Erase-remove idiom.
	gibs.erase(
		std::remove_if(
			gibs.begin(),
			gibs.end(),
			[]( const Gib &gib ){ return !gib.visible; }
		),
		gibs.end()
	);
}

void DrawFloorParticles(){
	static fgl::InstanceBuffer ib;
	// Create an instance for each gib.
	for( auto &gib : gibs_on_floor ){
		ib.push(
			{ fgl::fogColor.r * 0.9f, 0.0f, 0.0f, 1.0f },
			linalg::mul(
				linalg::pose_matrix(
					linalg::vec<double,4>( 0.0, 0.0, 0.0, 1.0 ),
					linalg::vec<double,3>( gib.x, -gib.y, gib.z )
				),
				linalg::scaling_matrix( linalg::vec<double,3>( 0.05, 0.05, 0.0 ) )
			)
		);
	}
	auto viewMat = world.viewMat;
	viewMat[3][0] += world.cameraX;
	viewMat[3][1] -= world.cameraY;
	auto projMat = linalg::perspective_matrix( 68.5 * 0.01745, (double)world.screenWidth / (double)world.screenHeight, 0.1, 44.0 );
	// Disable fog for the unlit instance pipeline.
	fgl::Color old_fog = fgl::fogColor;
	fgl::setFog( { 0.0f, 0.0f, 0.0f, 0.0f } );
	// Upload attributes.
	ib.upload();
	// Draw the instance buffer.
	auto old_pipeline = fgl::drawPipeline;
	fgl::setPipeline( fgl::unlitInstancePipeline );
	fgl::setTexture( fgl::blankTexture, 0 );
	ib.draw( fgl::cubeMesh, viewMat, projMat );
	ib.clear();
	// Set fog to its old color.
	fgl::setFog( old_fog );
	fgl::setPipeline( old_pipeline );
}

void DrawParticles(){
	static fgl::InstanceBuffer ib;
	// Create an instance for each gib.
	for( auto &gib : gibs ){
		ib.push(
			{ fgl::fogColor.r * 0.9f, 0.0f, 0.0f, 1.0f },
			linalg::mul(
				linalg::translation_matrix( linalg::vec<double,3>( gib.x, -gib.y, gib.z ) ),
				linalg::scaling_matrix( linalg::vec<double,3>( 0.05, 0.05, 0.05 ) )
			)
		);
	}
	// Create an instance for each raindrop.
	for( auto &drop : raindrops ){
		ib.push(
			(fgl::Color){ 0.17f, 0.3f, 0.35f, 0.75f },
			linalg::mul(
				linalg::pose_matrix(
					fgl::directionToQuat( { drop.vel_x, drop.vel_y, drop.vel_z }, { 0.0, 0.0, 1.0 } ),
					linalg::vec<double,3>( drop.x, -drop.y, drop.z )
				),
				linalg::scaling_matrix( linalg::vec<double,3>( 0.05, 0.05, 0.15 ) )
			)
		);
	}
	auto viewMat = world.viewMat;
	viewMat[3][0] += world.cameraX;
	viewMat[3][1] -= world.cameraY;
	auto projMat = linalg::perspective_matrix( 68.5 * 0.01745, (double)world.screenWidth / (double)world.screenHeight, 0.1, 44.0 );
	// Disable fog for the unlit instance pipeline.
	fgl::Color old_fog = fgl::fogColor;
	fgl::setFog( { 0.0f, 0.0f, 0.0f, 0.0f } );
	// Upload attributes.
	ib.upload();
	// Draw the instance buffer.
	auto old_pipeline = fgl::drawPipeline;
	fgl::setPipeline( fgl::unlitInstancePipeline );
	fgl::setTexture( fgl::blankTexture, 0 );
	ib.draw( fgl::cubeMesh, viewMat, projMat );
	ib.clear();
	// Set fog to its old color.
	fgl::setFog( old_fog );
	fgl::setPipeline( old_pipeline );
}

void DrawSettings(){
	double sw = fgl::getDisplayWidth(), sh = fgl::getDisplayHeight();
	double aspect = sw / sh, vnMinAspect = 5.0 / 4.0, vnMaxAspect = 1.78;
	double vnScale = sw / 1280.0;
	if( aspect < vnMinAspect ){
		vnScale = sh * vnMinAspect / 1280.0;
	}else if( aspect > vnMaxAspect ){
		vnScale = sh * vnMaxAspect / 1280.0;
	}
	float offset_x = sh * 0.125, offset_y = 0.0f;
	float offset_increment = vnScale * font_vn.size * 1.333;
	float button_width = 640.0 * vnScale + sh * 0.375;
	int button_index = convo.screen.ids.size();
	auto DrawProperty = [&]( std::string &name, std::string &value ){
		float button_x = ( sw - sh ) * 0.5 + offset_x;
		bool hover =
			!inputTarget &&
			show_cursor &&
			fgl::mouseY >= offset_y &&
			fgl::mouseY < offset_y + offset_increment &&
			fgl::mouseX >= button_x &&
			fgl::mouseX < button_x + button_width;
		if( hover ) choiceIndex = button_index;
		if(	button_index == choiceIndex && !inputTarget
			&& ( actionButtonDown || ( hover && mouseDownLeft ) ) ){
			// Do not propagate the click or button press.
			mouseDownLeft = false;
			actionButtonDown = false;
			// Open an inline text input.
			TextInputPrompt( &value );
		}
		glDisable( GL_BLEND );
		if( inputTarget == &value ){
			drawImage(
				fgl::blankTexture,
				button_x,
				offset_y,
				0.0,
				button_width / fgl::blankTexture.width,
				offset_increment / fgl::blankTexture.height
			);
			fgl::setTextInput(
				button_x,
				offset_y,
				sh,
				offset_increment
			);
			*inputTarget = StringSanitize( fgl::textInputString );
			// Submit the text by pressing the enter key or the A button
			// on a controller.
			if( fgl::textReturnStart || actionButtonDown ){
				// Do not propagate the button press.
				actionButtonDown = false;
				inputTarget = nullptr;
				fgl::setTextInput();
			}
		}else{
			drawImage(
				button_index == choiceIndex ? button_p : button_n,
				button_x,
				offset_y,
				0.0,
				button_width / button_n.width,
				offset_increment / button_n.height
			);
		}
		// Black text.
		glBlendFunc( GL_ZERO, GL_ONE_MINUS_SRC_COLOR );
		glEnable( GL_BLEND );
		fgl::drawText(
			" " + name,
			font_vn,
			button_x,
			offset_y,
			vnScale
		);
		fgl::drawText(
			( inputTarget == &value ) ? value + "_" : value,
			font_vn,
			sw * 0.5,
			offset_y,
			vnScale
		);
		offset_y += offset_increment;
		button_index++;
	};
	bool use_global = settings_category.empty()
		|| !settings.sections.contains( settings_category );
	glBlendFunc( GL_ONE, GL_ONE );
	glEnable( GL_BLEND );
	fgl::drawText(
		use_global ? ":: " : settings_category + " :: ",
		font_vn,
		( sw - sh ) * 0.5 + offset_x,
		offset_y,
		vnScale,
		-1
	);
	auto &properties = use_global
		? settings.global : settings.sections.get( settings_category );
	for( auto &property : properties ){
		DrawProperty( property.first, property.second );
	}
}

void DrawCharacterSelect(){
	std::vector<fworld::Item*> items = {};
	for( auto &item : world.items ){
		if( item.second.entity.type == "player" )
			items.push_back( &item.second );
	}
	// Abort if there are no items of type "player".
	if( items.empty() ){
		character_select_display = false;
		vnDataUpload();
		convo.go( character_next_screen );
		vnDataDownload( data_path );
		return;
	}
	choiceIndex =
		std::min( choiceIndex + moveXDown, (int)items.size() - 1 );
	if( moveXDown && choiceIndex < 0 ) choiceIndex = 0;
	double sw = fgl::getDisplayWidth(), sh = fgl::getDisplayHeight();
	float round_scale = std::round( sh / simScreenHeight2D );
	if( round_scale < 1.0f ) round_scale = 1.0f;
	float increment_x = tex_character_select.width * round_scale;
	float increment_y = tex_character_select.height * round_scale;
	float offset_x = ( sw - increment_x * items.size() ) * 0.5;
	float offset_y = ( sh - increment_y ) * 0.5;
	float absolute_font_height = sh / 720.0 * font_vn.size * 1.333;
	glEnable( GL_BLEND );
	// White text.
	glBlendFunc( GL_ONE, GL_ONE );
	fgl::drawText(
		"Select a confectioner",
		font_vn,
		sw * 0.5,
		offset_y - absolute_font_height,
		sh / 720.0,
		1
	);
	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
	for( size_t i = 0; i < items.size(); i++ ){
		fworld::Item *item_ptr = items[i];
		bool hover =
			!inputTarget &&
			show_cursor &&
			fgl::mouseY >= offset_y &&
			fgl::mouseY < offset_y + increment_y &&
			fgl::mouseX >= offset_x &&
			fgl::mouseX < offset_x + increment_x;
		if( hover ){
			choiceIndex = i;
		}
		drawImage(
			(size_t)choiceIndex == i ? button_p : button_n,
			offset_x + round_scale,
			offset_y + round_scale,
			0.0,
			( increment_x - round_scale * 2.0 ) / button_n.width,
			( increment_y - round_scale * 2.0 ) / button_n.height
		);
		drawImage(
			item_ptr->icon,
			offset_x,
			offset_y,
			0.0,
			round_scale,
			round_scale
		);
		if( (size_t)choiceIndex == i ){
			drawImage(
				tex_character_select,
				offset_x,
				offset_y,
				0.0,
				round_scale,
				round_scale
			);
		}
		offset_x += increment_x;
		if( ( hover && mouseDownLeft )
			|| ( (size_t)choiceIndex == i && actionButtonDown ) ){
			// Do not propagate the click or button press.
			mouseDownLeft = false;
			actionButtonDown = false;
			character_select_display = false;
			character_selected = item_ptr->entity.itemName;
			// Clear every drawing buffer, even with triple buffering.
			for( int n = 0; n < 3; n++ ){
				fgl::cls( { 0.0f, 0.0f, 0.0f, 1.0f } );
				fgl::sync();
			}
			vnDataUpload();
			convo.go( character_next_screen );
			vnDataDownload( data_path );
			return;
		}
	}
}

void DrawPopups( double d ){
	// Draw recipe popups at what amounts to a 1:1 pixel scale on a
	// sub-1080p display. Rounding errors are accounted for.
	float popup_scale =
		std::ceil( std::round( world.screenHeight / (double)simScreenHeight2D ) * 0.499 );
	if( popup_scale < 1.0f ){
		popup_scale = 1.0f;
	}

	float popup_margin = 3.0f;

	float popup_y = popup_margin * popup_scale;

	// Critical section.
	#ifndef __EMSCRIPTEN__
		ws_mutex.lock();
	#endif

	glEnable( GL_BLEND );

	for( auto &p : popups ){
		float popup_scale_mod = popup_scale;
		p.countdown -= d;
		if( p.countdown < 0.0 ){
			popup_scale_mod =
				popup_scale * ( 1.0 + popup_scale_speed * p.countdown );
			if( popup_scale_mod <= 0.0f ){
				popup_scale_mod = 0.0f;
				p.visible = false;
			}
		}
		float popup_x =
			( world.screenWidth - p.texture->width * popup_scale_mod ) * 0.5f;
		glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
		drawImage(
			*p.texture,
			popup_x,
			popup_y,
			0.0,
			popup_scale_mod,
			popup_scale_mod
		);
		glBlendFunc( GL_ONE, GL_ONE );
		fgl::drawText(
			p.s1 + "\n" + p.s2,
			font_vn,
			popup_x + p.texture->width * popup_scale_mod * 0.523f,
			popup_y + p.texture->height * popup_scale_mod * 0.06f,
			popup_scale_mod * 0.65,
			1 // Center.
		);
		popup_y += ( p.texture->height + popup_margin ) * popup_scale_mod;
	}
	// Removing elements within a for loop is dangerous, so we remove
	// afterwards with the erase-remove idiom.
	// https://stackoverflow.com/questions/8628951/remove-elements-of-a-vector-inside-the-loop
	popups.erase(
		std::remove_if(
			popups.begin(),
			popups.end(),
			[]( const Popup &p ){ return !p.visible; }
		),
		popups.end()
	);

	#ifndef __EMSCRIPTEN__
		ws_mutex.unlock();
	#endif
}

void DrawInventory(
		fworld::Inventory &inventory,
		float pos_x,
		float pos_y,
		float scale,
		std::vector<bool> &selections ){

	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
	drawImage( tex_boxes, pos_x, pos_y, 0.0, scale, scale );
	float item_x = pos_x;
	size_t i = 0;
	for( auto &item : inventory ){
		auto &tex = world.items[item.first].icon;
		if( show_cursor
			&& fgl::mouseY >= pos_y
			&& fgl::mouseY < pos_y + tex_boxes.height * scale
			&& fgl::mouseX >= item_x
			&& fgl::mouseX < item_x + tex.width * scale ){
			// Display item name.
			target_name = item.first;
			if( mouseDownLeft && selections.size() > i ){
				// Toggle item selection.
				selections[i] = !selections[i];
			}
		}
		if( selections.size() > i && selections[i] ){
			drawImage(
				tex_box_select,
				item_x,
				pos_y,
				0.0f,
				scale,
				scale
			);
		}
		drawImage( tex, item_x, pos_y, 0.0, scale, scale );
		item_x += tex.width * scale;
		i++;
	}
	glBlendFunc( GL_ONE, GL_ONE );
	item_x = pos_x;
	for( auto &item : inventory ){
		item_x += world.items[item.first].icon.width * scale;
		// Only show a number for stacks of multiple, zero, or negative items.
		if( item.second != 1.0 ){
			fgl::drawText(
				convo.stringifyNumber( item.second ),
				font_inventory,
				item_x,
				pos_y,
				scale * 0.5f,
				-1
			);
		}
	}
}

void DrawSpecialItems(
		float pos_x,
		float pos_y,
		float scale ){

	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
	float item_x = pos_x;
	size_t i = 0;
	for( auto &item : special_items ){
		auto &tex = item.icon;
		item_x -= tex.width * scale;
		bool selected = false;
		if( show_cursor
			&& fgl::mouseY >= pos_y
			&& fgl::mouseY < pos_y + tex.height * scale
			&& fgl::mouseX >= item_x
			&& fgl::mouseX < item_x + tex.width * scale ){
			selected = true;
			// Display the item name.
			target_name = item.name;
			if( mouseDownLeft ){
				// Do not propagate the click.
				mouseDownLeft = false;
				////////
				auto &inv =
					world.entities[world.followEntity].inventory;
				// Craft an item.
				std::vector<std::string> itemNames =
					{ std::to_string( item.id ) };
				for( size_t j = 0; j < item_selected.size(); j++ ){
					if( item_selected[j] ){
						itemNames.push_back( inv[j].first );
					}
				}
				std::string resultName;
				char status = world.craft(
					world.followEntity,
					itemNames,
					inventory_slots,
					resultName
				);
				if( status == 'b' || status == 'n' || status == 'i' ){
					// Either stuff is used up, there is no match, or
					// something is insufficient.
					// Deselect all.
					item_selected.assign( inv.size(), false );
				}
				if( resultName.length() > 0
					&& world.items[resultName].yield ){
					AddRecipe( resultName );
					csPlaySound( "craft", false );
				}
				if( itemNames.size() == 1 ){
					// Open the corresponding dialogue file if it exists
					// in the current language.
					std::string dialogue_name =
						std::to_string( item.id ) + ".json";
					FILE *file = FileOpen(
						( data_path + "/dialogue_" + language + "/" + dialogue_name ).c_str(),
						"rb"
					);
					if( file ){
						fclose( file );
						vnDataUpload();
						convo.go( dialogue_name );
						vnDataDownload( data_path );
					}
				}
				////////
			}
		}
		if( selected )
			fgl::setFog( { 1.0f, 1.0f, 1.0f, 0.9f } );
		else
			fgl::setFog( { 0.8f, 0.8f, 0.8f, 0.6f } );
		// Draw the item.
		drawImage( tex, item_x, pos_y, 0.0, scale, scale );
		i++;
	}
	fgl::setFog( { 1.0f, 1.0f, 1.0f, 1.0f } );
	glBlendFunc( GL_ONE, GL_ONE );
	item_x = pos_x;
	for( auto &item : special_items ){
		// Only show a number for stacks of multiple items.
		if( item.count > 1 ){
			fgl::drawText(
				std::to_string( item.count ),
				font_inventory,
				item_x,
				pos_y,
				scale * 0.5f,
				-1
			);
		}
		item_x -= item.icon.width * scale;
	}
}

void GameLoop( double d ){
	// Only freeze time when offline or a non-chat text prompt is used.
	if( paused && ( !multiplayer
		|| ( inputTarget && inputTarget != &ws_chat_message ) ) ){
		d = 0.0;
	}

	double sw = world.screenWidth, sh = world.screenHeight;

	double aspect = sw / sh;
	double vnScale = sw / 1280.0;
	double hudOffX = 0.0;
	double vnMinAspect = 5.0 / 4.0, vnMaxAspect = 1.78;
	if( aspect < vnMinAspect ){
		vnScale = sh * vnMinAspect / 1280.0;
	}else if( aspect > vnMaxAspect ){
		vnScale = sh * vnMaxAspect / 1280.0;
		hudOffX = ( sw - sh * vnMaxAspect ) * 0.5;
	}

	// Draw pixel art UI elements at an integer scale for quality.
	float round_scale = std::round( sh / simScreenHeight2D );
	if( round_scale < 1.0f ) round_scale = 1.0f;
	// Inventory offset X.
	float boxOffX = sw - tex_boxes.width * round_scale;

	static int itemCycleLast;
	int itemCycle = ( fgl::right2() > 0.1f ) - ( fgl::left2() > 0.1f )
		- fgl::mouseWheel;
	int itemCycleDown = itemCycle == itemCycleLast ? 0 : itemCycle;
	itemCycleLast = itemCycle;

	// Clear the target name.
	target_name = "";

	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );

	if( convo.screen.bg.empty() ){
		// Draw the world if there is one.
		if( world.map.empty() ){
			// Nothing to do, so quit.
			Quit();
		}

		auto &player = world.entities[world.followEntity];

		if( character_selected.length() > 0 ){
			// Override player entity.
			bool name_input = inputTarget == &player.name;
			auto p_x = player.x;
			auto p_y = player.y;
			auto p_direction = player.direction;
			player = world.items[character_selected].entity;
			character_selected = "";
			player.x = p_x;
			player.y = p_y;
			player.direction = p_direction;
			player.type = "spawn";
			if( name_input ) inputTarget = &player.name;
			// Start with the default time of day in single-player.
			if( !multiplayer ) player.age = default_time * day_duration;
		}

		// Default to normal walking speed.
		player.speedFactor = 1.0;
		if( convo.screen.ids.size() > 0 || paused ){
			// There is a button prompt, so stop the player from walking.
			if( player.path.size() == 0 ){
				player.walking = false;
				player.frame = 0.0;
			}
		}else if( pauseButtonDown ){
			// The pause button is pressed when the player is free to move.
			// Bring up the 2D pause menu.
			PauseMenu( "pause2d.json" );
		}else if( recipesButtonDown ){
			// Bring up the Recipes menu.
			if( !fgl::controller )
				show_cursor = true;
			paused = true;
			recipes_display = true;
			// Do not propagate the button press.
			recipesButtonDown = false;
		}else{
			// The player is free to move.
			// TODO: Allow customizing "run" key.
			float stick_mag =
				std::sqrt( stickX * stickX + stickY * stickY );
			// A transition zone between walking and sprinting.
			if( stick_mag > ( dead_zone + speed_zone ) * 0.5f )
				player.speedFactor = 1.5;
			bool sprinting = false;
			if( fgl::shiftKey() || stick_mag > speed_zone ){
				player.speedFactor = 2.0;
				sprinting = player.walking;
				if( player.walking && !csSoundIsPlaying( "footstep1" )
					&& !csSoundIsPlaying( "footstep2" ) ){
					// Play a random footstep sound.
					int r = std::rand() % 2;
					csPlaySound( r ? "footstep1" : "footstep2", false );
				}
			}
			if( !sprinting ){
				// Stop footstep sound effects.
				csStopLoadedSound( sound_specs["footstep1"].loaded_ptr );
				csStopLoadedSound( sound_specs["footstep2"].loaded_ptr );
			}
			if( meleeButtonDown && player.stun <= 0.0 ){
				// Trigger melee.
				player.task = fworld::TASK_MELEE;
				player.stun = player.meleeRecovery;
				player.frame = 0.0;
				if( world.facingEntity >= 0
					&& (int)world.entities.size() > world.facingEntity ){
					// Hit.
					auto &ent = world.entities[world.facingEntity];
					bool trigger_death = false;
					int blood_particles = 0;
					if( ent.type == "fighter" ){
						// Melee combat.
						blood_particles = player.meleeDamage;
						ent.health -= player.meleeDamage;
						if( ent.health <= 0 ){
							fighters = fighter::GetFighters( &world );
							trigger_death = true;
						}
					}else if( ent.type == "corpse" ){
						// Gib corpse.
						uint32_t gseed = std::rand();
						for( int g = 0; g < 12; g++ ){
							MakeGibs(
								ent.x + 0.5,
								ent.y + 0.5,
								linalg::normalize( linalg::vec<double,3>(
									proc::RandFloat( &gseed ) - 0.5,
									proc::RandFloat( &gseed ) - 0.5,
									0.0 ) ) * 10.0,
								10
							);
						}
						// Play a random splat sound.
						int r = std::rand() % 2;
						csPlaySound( r ? "splat1" : "splat2", false );
						if( world.items.find( "Raw Meat" )
							!= world.items.end() ){
							// Replace the corpse with raw meat.
							double x = ent.x, y = ent.y;
							ent = world.items["Raw Meat"].entity;
							ent.x = x;
							ent.y = y;
						}
					}else if( ent.bribeItems.contains( "melee" ) ){
						// Non-fighter responds to melee attacks.
						blood_particles = player.meleeDamage;
						ent.health -= player.meleeDamage;
						if( ent.bribeSprite.success )
							ent.sprite = ent.bribeSprite;
						if( ent.type == "sentry" ){
							// TODO: Make it a civilian bird??? Whatever.
							ent.type = "bird";
							ent.staticCollisions = false;
							civilians = civilian::GetCivilians( &world );
							world.recalculateMapEntities();
						}
						if( ent.health <= 0 && ent.type == "bird" ){
							ent.bribeItems = {};
							civilians = civilian::GetCivilians( &world );
							trigger_death = true;
						}
					}else if( ent.meleeSprite.success
						&& ( ent.type == "static"
							|| ent.type == "sentry"
							|| ent.type == "civilian" ) ){
						// The entity will now fight the player.
						blood_particles = player.meleeDamage;
						ent.type = "fighter";
						if( ent.animationMode < 0
							&& ent.bribeSprite.success ){
							// Default to RPG animations.
							ent.animationMode = fworld::ANIMATION_RPG;
							ent.sprite = ent.bribeSprite;
							ent.width = ent.sprite.width / 4;
							ent.height = ent.sprite.height / 3;
						}
						ent.staticCollisions = false;
						civilians = civilian::GetCivilians( &world );
						fighters = fighter::GetFighters( &world );
						world.recalculateMapEntities();
						ent.health -= player.meleeDamage;
						if( ent.health <= 0 ) trigger_death = true;
					}
					MakeGibs(
						ent.x + 0.5,
						ent.y + 1.0 - ent.height * 0.75 / world.tileSize,
						linalg::normalize( linalg::vec<double,3>( ent.x - player.x, ent.y - player.y, 0.0 ) ) * 10.0,
						blood_particles
					);
					if( trigger_death ) KillEntity( ent );
					csPlaySound( "melee_hit", false );
				}else{
					// Miss.
					csPlaySound( "melee_miss", false );
				}
			}else if( ( entityButtonDown
				|| ( player.task == fworld::TASK_DROP && player.path.empty() ) )
				&& player.stun <= 0.0 && somethingSelected ){
				// Do not propagate the task.
				player.task = fworld::TASK_NONE;
				// Trigger entity placement.
				static const long long // Up, down, right, left.
					offset_x[] = {  0, 0, 1, -1 },
					offset_y[] = { -1, 1, 0,  0 };
				long long
					place_x = player.x + 0.5,
					place_y = player.y + 0.5;
				// Only offset the entity when pushing the button.
				if( entityButtonDown ){
					// Do not propagate the button press.
					entityButtonDown = false;
					place_x += offset_x[player.direction];
					place_y += offset_y[player.direction];
				}
				if( place_x < 0 ) place_x = 0;
				if( place_y < 0 ) place_y = 0;
				if( place_x > (long long)world.mapInfo[0].size() - 1 )
					place_x = world.mapInfo[0].size() - 1;
				if( place_y > (long long)world.mapInfo.size() - 1 )
					place_y = world.mapInfo.size() - 1;
				if( !world.tileBlocking( place_x, place_y, true )
					&& world.mapEntities[place_y][place_x] < 0xB0 ){
					// Iterate in reverse order to place the last valid
					// entity.
					for( int i = (int)item_selected.size() - 1; i >= 0; i-- ){
						auto &item =
							world.items[player.inventory[i].first];
						if( item_selected[i]
							&& player.inventory[i].second > 0.0
							&& item.entity.sprite.success ){
							AddEntity( item.entity, place_x, place_y );
							player.inventory[i].second--;
							if( player.inventory[i].second == 0.0 ){
								// Remove the item from the player's inventory.
								player.inventory.erase( player.inventory.begin() + i );
								// Deselect all.
								item_selected.assign( player.inventory.size(), false );
								somethingSelected = false;
							}
							// TODO: Sound.
							break;
						}
					}
				}
			}else if( fgl::keystates[/*SDL_SCANCODE_GRAVE*/ SDL_SCANCODE_SLASH]
				&& !fgl::textInputEnabled ){
				// Trigger command input.
				// Critical section.
				#ifndef __EMSCRIPTEN__
					ws_mutex.lock();
				#endif
				// Set the string to display and the destination address.
				ws_chat_message = "/";
				TextInputPrompt( &ws_chat_message, "Enter message" );
				// Clear the string to avoid sending prematurely.
				ws_chat_message = "";
				#ifndef __EMSCRIPTEN__
					ws_mutex.unlock();
				#endif
			}else if( sleepButtonDown && !sleep_mode
				&& !fgl::textInputEnabled ){
				// Trigger sleep mode or text input, depending on
				// context.
				// Critical section.
				#ifndef __EMSCRIPTEN__
					ws_mutex.lock();
				#endif
				if( multiplayer ){
					TextInputPrompt( &ws_chat_message, "Enter message" );
				}else if( world.getEntityZone( world.followEntity ) >= 0 ){
					Notify( &tex_notification, "Can't rest here", "" );
				}else{
					player.task = fworld::TASK_SLEEP;
					player.stun = sleep_duration;
				}
				#ifndef __EMSCRIPTEN__
					ws_mutex.unlock();
				#endif
			}
			if( player.stun > 0.0 ){
				// Cancel movement.
				moveX = 0.0f;
				moveY = 0.0f;
				circleX = DBL_INF;
				circleY = DBL_INF;
				if( player.path.size() > 0 ){
					player.path.clear();
				}
			}else if( player.task == fworld::TASK_MELEE
				|| player.task == fworld::TASK_SLEEP ){
				// Cancel task.
				player.task = fworld::TASK_NONE;
			}
			sleep_mode =
				player.task == fworld::TASK_SLEEP && player.stun > 0.0;
			if( sleep_mode ){
				// Sleep mode.
				player.staticCollisions = true;
				// TODO: Run TurboSleep at fixed 30-60 hz. IMPORTANT!
				if( true ){
					TurboSleep();
				}
			}else{
				// Not in sleep mode.
				player.staticCollisions = false;
			}
			if( std::sqrt( moveX * moveX + moveY * moveY ) > dead_zone ){
				// Movement input has exceeded the analog dead zone.
				player.task = fworld::TASK_NONE;
				if( player.path.size() > 0 ){
					player.path.clear();
				}
			}
			world.moveEntity( player, moveX, moveY, d );
		}

		world.cursorX = fgl::mouseX;
		world.cursorY = fgl::mouseY;

		// Day/night tinting.
		fgl::setFog( ambient_light );

		if( !sleep_mode ){
			world.simulate( d );
		}

		world.drawMap();
		SimulateParticles( d );
		DrawFloorParticles();

		// Display a circle under the player's target.
		if( circleX != DBL_INF && circleY != DBL_INF ){
			// A grey circle indicates that the player character only moves to the target.
			// An orange circle indicates that the player character performs a task at the target.
			fgl::Texture &circleTex =
				player.task == fworld::TASK_NONE ? tex_circle_grey : tex_circle_orange;
			world.drawSprite(
				circleTex,
				( circleX - world.cameraX ) * (double)world.tileSize * world.scale + sw * 0.5,
				( circleY - world.cameraY ) * (double)world.tileSize * world.scale + sh * 0.5,
				world.scale,
				0,
				0,
				circleTex.width,
				circleTex.height,
				true
			);
		}

		// Critical section.
		#ifndef __EMSCRIPTEN__
			ws_mutex.lock();
		#endif
		// Interpolate and animate ws_entities.
		for( auto &e : ws_entities ){
			e.stun = std::max( e.stun - d, 0.0 );
			if( e.task == fworld::TASK_MELEE
				&& ( e.stun <= 0.0 || e.frame >= 2.0 ) ){
				e.task = fworld::TASK_NONE;
				e.stun = 0.0;
				e.frame = 2.0;
			}
			// Play animations.
			if( e.task == fworld::TASK_MELEE ){
				e.frame = std::min( e.frame + e.fps * d, 2.0 );
			}else if( e.walking ){
				e.frame = std::fmod( e.frame + e.fps * d, 4.0 );
			}
			// Calculate velocity.
			double vel_x = ( e.x - e.last_x ) / tick_duration;
			double vel_y = ( e.y - e.last_y ) / tick_duration;
			// Skip interpolation if velocity is over 20 m/s.
			// (Like when warping.)
			if( std::sqrt( vel_x * vel_x + vel_y * vel_y ) > 20.0 ){
				e.last_x = e.x;
				e.last_y = e.y;
			}else{
				// Increase the movement speed when in melee to get to a
				// more accurate entity position.
				if( e.task == fworld::TASK_MELEE ){
					vel_x *= 1.2;
					vel_y *= 1.2;
				}
				e.x += vel_x * d;
				e.y += vel_y * d;
				e.last_x += vel_x * d;
				e.last_y += vel_y * d;
			}
		}
		if( multiplayer )
			world.drawEntities( ws_entities );
		else
			world.drawEntities();
		DrawParticles();
		#ifndef __EMSCRIPTEN__
			ws_mutex.unlock();
		#endif

		if( convo.screen.ids.empty() && !paused ){
			// No dialogue is taking place and the game is not paused.
			if( player.task == fworld::TASK_TALK && !trading_display ){
				player.task = fworld::TASK_NONE;
				circleX = DBL_INF;
				circleY = DBL_INF;
			}

			glEnable( GL_BLEND );

			// The mouse is not on the player's inventory and not
			// obstructed, occluded, or blocked in any way.
			bool mouse_on_world =
				( fgl::mouseX < boxOffX || fgl::mouseY >= tex_boxes.height * round_scale );
			if( special_items.size() > 0 ){
				fgl::Texture &item_icon = special_items[0].icon;
				if( fgl::mouseX >= sw - special_items.size() * item_icon.width * round_scale
					&& fgl::mouseY < ( tex_boxes.height + item_icon.height ) * round_scale )
					mouse_on_world = false;
			}

			if( !show_cursor && world.facingEntity >= 0 && player.path.size() == 0 ){
				// Handle entities the player faces with a controller.
				auto &ent = world.entities[world.facingEntity];
				if( ent.task != fworld::TASK_SLEEP || ent.type != "regrow" )
					target_name = ent.name;
				if( actionButtonDown ){
					// Do not propagate the button press.
					actionButtonDown = false;
					// Trigger an interaction.
					WorldInteract( world.facingEntity );
				}
			}else if( mouse_on_world
				&& world.cursorOverEntity >= 0 && show_cursor ){
				// The cursor is visible over an entity and not over the inventory.
				auto &ent = world.entities[world.cursorOverEntity];
				if( ent.task != fworld::TASK_SLEEP || ent.type != "regrow" )
					target_name = ent.name;
				//drawImage( tex_actions, fgl::mouseX - 36.0 * round_scale, fgl::mouseY - 36.0 * round_scale, 0.0, round_scale, round_scale );
				if( mouseDownLeft ){
					// If there is interaction, do not propagate the click.
					mouseDownLeft =
						!WorldInteract( world.cursorOverEntity );
				}
			}

			// There are no button prompts on the screen, so walk a path
			// to wherever the player is right-clicking or tapping.
			if( (
					mouseRight
					|| ( mouse_on_world && mouseDownLeft )
				) && show_cursor && player.task != fworld::TASK_SLEEP ){
				world.solveEntityPath( player, (long long)( world.cursorWorldX ), (long long)( world.cursorWorldY ), true );
				if( player.path.size() > 0 && !world.mapChanged )
					player.task = fworld::TASK_NONE;
				if( mouseDownLeft ){
					// Do not propagate the click.
					mouseDownLeft = false;
					if( somethingSelected ){
						// Walk a path to the drop site.
						player.task = fworld::TASK_DROP;
						circleX = (long long)( world.cursorWorldX );
						circleY = (long long)( world.cursorWorldY );
					}
				}
			}else if( player.path.size() > 0
				&& circleX == DBL_INF && circleY == DBL_INF ){
				// Remove the path if there is no circle.
				player.path.clear();
			}
			// Move the silver circle towards the path target.
			if( player.task == fworld::TASK_NONE
				&& player.path.size() > 0 && !world.mapChanged ){
				// Get a target from the player's path.
				intptr_t index = (intptr_t)player.path[player.path.size() - 1];
				size_t MapW = world.mapInfo[0].size();
				double newCircleY = index / MapW;
				double newCircleX = index - newCircleY * MapW;
				if( circleX == DBL_INF && circleY == DBL_INF ){
					circleX = newCircleX;
					circleY = newCircleY;
				}else{
					// Slide the circle smoothly from one point to another.
					double f = 30.0 * d;
					if( f > 1.0 ){
						f = 1.0;
					}
					circleX = lerp( circleX, newCircleX, f );
					circleY = lerp( circleY, newCircleY, f );
				}
			}
			if( player.task == fworld::TASK_BUMP ){
				if( ( bumpIndex >= 0 && (size_t)bumpIndex < world.entities.size()
					&& bumpIndex != world.followEntity
					&& world.entitiesBumping( player, world.entities[bumpIndex] ) )
					|| ( ( bumpIndex = GetEntityAt( circleX, circleY ) ) >= 0
					&& world.entitiesBumping( player, world.entities[bumpIndex] ) ) ){
					// As a last resort, bumpIndex is reassigned to whatever is there.
					auto &ent = world.entities[bumpIndex];
					if( ent.type == "flora" || ent.type == "pickup"
						|| ( ent.task == fworld::TASK_NONE && ent.type == "regrow" ) ){
						// Collect if there is room in the player's inventory for an item.
						if( world.inventoryCanTakeAny(
								player.inventory,
								ent.inventory,
								inventory_slots ) ){
							// Play a sound.
							csPlaySound( "item", false );
							// Copy the entity's items. This is important for regrowth.
							auto ent_items = ent.inventory;
							// Take the items.
							world.transferInventory(
								ent_items,
								player.inventory,
								inventory_slots
							);
							// Activate dialogue if there is any.
							if( ent.dialogue.length() > 0
									&& ent.task != fworld::TASK_SLEEP ){
								vnDataUpload();
								convo.go( ent.dialogue );
								vnDataDownload( data_path );
							}
							if( ent.type == "flora" ){
								// Remove the plant.
								flora::KillEntity( bumpIndex, &plants, &world );
							}else if( ent.type == "pickup" ){
								// Remove the entity.
								world.removeEntity( bumpIndex );
								// Reload plant indices.
								plants = flora::GetPlants( &world );
							}else{ // `regrow` type.
								// Make the entity sleep.
								ent.task = fworld::TASK_SLEEP;
								// Sleep the entity for 1 plant growth period.
								ent.stun = plant_grow_period;
								// Reload plant indices.
								plants = flora::GetPlants( &world );
							}
							// Reload AI indices.
							civilians = civilian::GetCivilians( &world );
							fighters = fighter::GetFighters( &world );
							//wolves = wolf::GetWolves( &world, seed_dist( mt ) );
						}
						// Deselect all.
						// Notice how the "player" keyword is not used
						// below the removeEntity call. This is because
						// the player index can change.
						item_selected.assign(
							world.entities[world.followEntity].inventory.size(),
							false
						);
						somethingSelected = false;
					}else if( bumpIndex != world.followEntity ){
						// Inventory-to-entity crafting.
						std::vector<std::string> itemNames = { ent.name };
						for( size_t i = 0; i < item_selected.size(); i++ ){
							if( item_selected[i] ){
								itemNames.push_back( player.inventory[i].first );
							}
						}
						std::string resultName;
						char status = world.craft(
							world.followEntity,
							itemNames,
							inventory_slots,
							resultName
						);
						if( status == 'b' || status == 'n' || status == 'i' ){
							// Either stuff is used up, there is no
							// match, or something is insufficient.
							// Deselect all.
							item_selected.assign( player.inventory.size(), false );
							somethingSelected = false;
						}
						if( resultName.length() > 0
							&& world.items[resultName].yield ){
							AddRecipe( resultName );
							csPlaySound( "craft", false );
						}
					}
					// Stop walking.
					player.path.clear();
					bumpIndex = -1;
				}
			}
			size_t player_path_size =
				world.entities[world.followEntity].path.size();
			if( player_path_size <= 1 && trading_display
				&& !trading_entity ){
				// Look for an in-range trading partner.
				BeginTrading();
			}
			if( player_path_size == 0 || trading_entity ){
				// Remove the circle if there is no path or trading has
				// started.
				circleX = DBL_INF;
				circleY = DBL_INF;
				if( player.task == fworld::TASK_BUMP )
					player.task = fworld::TASK_NONE;
			}
		}

		// This is where circles would be drawn if they were on top.
	}

	// It is once again safe to use pathfinding.
	world.mapChanged = false;

	// Use the screen as the drawing surface.
	fgl::setFramebuffer();
	//fgl::cls( { 0.0f, 0.0f, 0.0f, 1.0f } );
	glClear( GL_DEPTH_BUFFER_BIT );
	fgl::setPipeline( bloomPipeline );

	// Fog is used for bloom parameters.
	fgl::setFog( {
		GLfloat( bloomSizeX * world.scale ),
		GLfloat( bloomSizeY * world.scale ),
		bloomThreshold,
		( health < threshold_health && !world.map.empty() && !mainmenu )
			? ( 0.0f ) : ( bloomAmount )
	} );
	fgl::drawFramebuffer( framebuffer );

	fgl::setPipeline( fgl::colorModPipeline );

	fgl::setFog( { 1.0f, 1.0f, 1.0f, 1.0f } );

	// Apply world entity variables to the VN part of the game for display.
	if( world.followEntity >= 0 && world.entities.size() >= 1 ){
		auto &player = world.entities[world.followEntity];
		health = player.health;
		money = player.money;
		if( health <= 0 && !paused
			&& !mainmenu && !convo.getVariable( "mainmenu" ) ){
			// Kill the player.
			player.task = fworld::TASK_SLEEP;
			PauseMenu( "death.json" );
			csPlaySound( "death", false );
		}
	}

	vnDraw( sw, sh );

	// Display special screens: Either recipes, trading, sequencer, or
	// cake.
	if( recipes_display ){
		// Draw recipes below the bottom edge of the inventory boxes.
		float offset_y = tex_boxes.height * round_scale;
		glBlendFunc( GL_ONE, GL_ONE );
		fgl::drawText(
			"Recipes",
			font_vn,
			sw * 0.5,
			offset_y,
			vnScale,
			1
		);
		offset_y += vnScale * font_vn.height * 1.333;
		// List the unlocked recipes.
		// Draw recipe font at what amounts to a 1:1 pixel scale on a
		// sub-1080p display. Rounding errors are accounted for.
		float recipe_scale = std::ceil( round_scale * 0.499 );
		if( recipe_scale < 1.0f ) recipe_scale = 1.0f;
		int i = 0;
		for( auto &r : recipes ){
			if( r.second.unlocked ){
				auto &item = world.items[r.first];
				// Alternate between left and right columns.
				float offset_x = i % 2 ? sw * 0.5 : sw * 0.5
					- tex_recipe_overlay.width * round_scale
					- item.icon.width * round_scale * 3.0;
				glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
				drawImage(
					item.icon,
					offset_x,
					offset_y,
					0.0f,
					round_scale,
					round_scale
				);
				drawImage(
					tex_recipe_overlay,
					offset_x,
					offset_y,
					0.0f,
					round_scale,
					round_scale
				);
				float ingredient_x =
					offset_x + tex_recipe_overlay.width * round_scale;
				if( fgl::mouseX >= offset_x && fgl::mouseY >= offset_y
					&& fgl::mouseX < ingredient_x
					&& fgl::mouseY < offset_y + item.icon.height * round_scale ){
					target_name = r.first;
				}
				for( auto &ingredient : item.recipe ){
					auto &tex = world.items[ingredient.first].icon;
					drawImage(
						tex,
						ingredient_x,
						offset_y,
						0.0f,
						round_scale,
						round_scale
					);
					ingredient_x += tex.width * round_scale;
				}
				if( i % 2 ){
					offset_y += item.icon.height * round_scale;
				}
				i++;
			}
		}
		// Hide the Recipes menu with a button press.
		if( recipesButtonDown || pauseButtonDown ){
			paused = false;
			recipes_display = false;
		}
	}else if( trading_display && trading_entity ){
		float offset_x = ( sw - tex_boxes.width * round_scale ) * 0.5;
		float offset_y = sh * 0.5 - tex_boxes.height * round_scale;
		// Draw trading_entity's items and allow selection.
		DrawInventory(
			trading_entity->inventory,
			offset_x,
			offset_y,
			round_scale,
			partner_item_selected
		);
		bool looting = ( trading_entity->task == fworld::TASK_SLEEP
			&& trading_entity->type == "corpse" )
			|| trading_entity->type == "crate";
		// Draw text.
		glBlendFunc( GL_ONE, GL_ONE );
		if( looting ){
			fgl::drawText(
				"Looting " + trading_entity->name,
				font_vn,
				sw * 0.5,
				offset_y - vnScale * font_vn.height * 1.1665,
				vnScale,
				1
			);
		}else{
			fgl::drawText(
				trading_entity->name + "'s inventory",
				font_vn,
				sw * 0.5,
				offset_y - vnScale * font_vn.height * 1.1665,
				vnScale,
				1
			);
			int total = GetTradingTotal();
			fgl::drawText(
				" You gain: " + std::string( total < 0 ? "-$" : "$" )
					+ std::to_string( std::abs( total ) ),
				font_vn,
				offset_x,
				sh * 0.5,
				vnScale
			);
		}
	}else if( sequencer_display ){
		float offset_y = tex_boxes.height * round_scale;
		glBlendFunc( GL_ONE, GL_ONE );
		fgl::drawText(
			sequencer_name,
			font_vn,
			sw * 0.5,
			offset_y,
			vnScale,
			1
		);
		offset_y += vnScale * font_vn.height * 1.333;
		// TODO.
	}else if( cake_display ){
		float offset_y = tex_boxes.height * round_scale;
		glBlendFunc( GL_ONE, GL_ONE );
		fgl::drawText(
			"Cake",
			font_vn,
			sw * 0.5,
			offset_y,
			vnScale,
			1
		);
		offset_y += vnScale * font_vn.height * 1.333;
		// TODO.
	}

	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );

	// Inventory. Display only if health > 0, settings menu is not open,
	// and character select screen is not open.
	if( world.followEntity >= 0 && convo.screen.bg.empty()
		&& health > 0 && !settings_display && !character_select_display ){
		drawImage( tex_boxes, boxOffX, 0.0, 0.0, round_scale, round_scale );
		float itemOffX = boxOffX;
		auto &inv = world.entities[world.followEntity].inventory;
		// Synchronize the selection boxes with the current size of the inventory.
		item_selected.resize( inv.size() );
		// Item selections via keys and buttons.
		if( itemCycleDown ){
			if( somethingSelected ){
				// Select the item to the left or right of the currently
				// selected items.
				int pos = -1;
				for( size_t i = 0; i < item_selected.size(); i++ ){
					if( item_selected[i] ){
						if( itemCycleDown > 0 ){
							// Select the item to the right of the
							// rightmost selected item.
							pos = i + 1;
							if( (size_t)pos >= item_selected.size() ){
								pos = -1;
							}
						}else{
							// Select the item to the left of the
							// leftmost selected item.
							pos = i - 1;
							break;
						}
					}
				}
				// Deselect all.
				item_selected.assign( inv.size(), false );
				// Select the item if applicable.
				somethingSelected = pos != -1;
				if( somethingSelected ){
					item_selected[pos] = true;
				}
			}else if( inv.size() > 0 ){
				// Select the leftmost or rightmost item.
				item_selected[itemCycleDown > 0 ? 0 : inv.size() - 1] = true;
				somethingSelected = true;
			}
		}
		bool somethingWasSelected = somethingSelected;
		somethingSelected = false;
		int i = 0;
		for( auto &item : inv ){
			auto &tex = world.items[item.first].icon;
			if( show_cursor
				&& fgl::mouseY < tex_boxes.height * round_scale
				&& fgl::mouseX >= itemOffX && fgl::mouseX < itemOffX + tex.width * round_scale ){
				// Display item name.
				target_name = item.first;
				if( mouseDownLeft ){
					// Do not propagate the click.
					mouseDownLeft = false;
					if( somethingWasSelected
						&& !item_selected[i]
						&& !fgl::shiftKey() ){
						// In-inventory crafting.
						std::vector<std::string> itemNames = { inv[i].first };
						for( size_t j = 0; j < item_selected.size(); j++ ){
							if( item_selected[j] ){
								itemNames.push_back( inv[j].first );
							}
						}
						std::string resultName;
						char status = world.craft(
							world.followEntity,
							itemNames,
							inventory_slots,
							resultName
						);
						if( status == 'b' || status == 'n' || status == 'i' ){
							// Either stuff is used up, there is no
							// match, or something is insufficient.
							// Deselect all.
							item_selected.assign( inv.size(), false );
						}
						if( resultName.length() > 0
							&& world.items[resultName].yield ){
							AddRecipe( resultName );
							csPlaySound( "craft", false );
						}
						if( status == 'a' || status == 'b' ){
							// The structure of the inventory may have changed.
							// It is not safe to continue the loop.
							break;
						}
					}else{
						// Toggle item selection.
						item_selected[i] = !item_selected[i];
					}
				}
			}
			if( item_selected[i] ){
				somethingSelected = true;
				drawImage( tex_box_select, itemOffX, 0.0, 0.0, round_scale, round_scale );
			}
			drawImage( tex, itemOffX, 0.0, 0.0, round_scale, round_scale );
			itemOffX += tex.width * round_scale;
			i++;
		}
		glBlendFunc( GL_ONE, GL_ONE );
		itemOffX = boxOffX;
		for( auto &item : world.entities[world.followEntity].inventory ){
			itemOffX += world.items[item.first].icon.width * round_scale;
			// Only show a number for stacks of multiple, zero, or negative items.
			if( item.second != 1.0 ){
				fgl::drawText(
					convo.stringifyNumber( item.second ),
					font_inventory,
					itemOffX,
					0.0f,
					round_scale * 0.5f,
					-1
				);
			}
		}
		// Draw the special items.
		DrawSpecialItems( sw, tex_boxes.height * round_scale, round_scale );
		glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
	}

	if( target_name.length() > 0 ){
		if( language == "zh" ){
			// Translate from English to Mandarin Chinese.
			target_name = EnToZh( target_name );
		}
		// Black shadow.
		glBlendFunc( GL_ZERO, GL_ONE_MINUS_SRC_COLOR );
		fgl::drawText(
			target_name,
			font_vn,
			hudOffX + vnScale * 3.0,
			font_mono.height * vnScale + vnScale,
			vnScale
		);
		// Yellow text.
		glBlendFunc( GL_ONE, GL_ONE );
		auto tmp_fog = fgl::fogColor;
		//fgl::setFog( { 1.0f, 1.0f, 0.15f, 1.0f } );
		fgl::setFog( { 1.0f, 0.94f, 0.04f, 1.0f } );
		fgl::drawText(
			target_name,
			font_vn,
			hudOffX + vnScale * 2.0,
			font_mono.height * vnScale,
			vnScale
		);
		glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
		fgl::setFog( tmp_fog );
	}

	// Draw the chat buffer.
	std::string chat_text = "";
	// Critical section.
	#ifndef __EMSCRIPTEN__
		ws_mutex.lock();
	#endif
	for( auto &m : chat_buffer ){
		chat_text += m + "\n";
	}
	#ifndef __EMSCRIPTEN__
		ws_mutex.unlock();
	#endif
	// Black shadow.
	glBlendFunc( GL_ZERO, GL_ONE_MINUS_SRC_COLOR );
	fgl::drawText(
		chat_text,
		font_vn,
		hudOffX + vnScale * 3.0,
		( font_mono.height + font_vn.height ) * vnScale + vnScale,
		vnScale * 0.75
	);
	// White text.
	glBlendFunc( GL_ONE, GL_ONE );
	fgl::drawText(
		chat_text,
		font_vn,
		hudOffX + vnScale * 2.0,
		( font_mono.height + font_vn.height ) * vnScale,
		vnScale * 0.75
	);
	glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
}

void FirstPersonLoop( double d ){
#ifdef __LIGHT__
	(void)d;
#else
	// Bring up the 3D pause menu when escape or start is pressed.
	if( convo.screen.ids.empty() && !paused && pauseButtonDown ){
		PauseMenu( "pause3d.json" );
	}

	if( health <= 0 && !paused ){
		// Kill the player.
		PauseMenu( "death.json" );
		csPlaySound( "death", false );
	}

	if( paused ){
		d = 0.0;
	}

	if( dungeon.playerAgent && convo.screen.ids.empty() ){
		// Rotate the camera with the mouse.
		dungeon.moveMouse( fgl::mouseMoveX, fgl::mouseMoveY );

		// Rotate the camera with the right joystick.
		dungeon.rotateAgentConstrained( dungeon.playerAgent, {
			fgl::rightStickY() * -2.5 * d,
			fgl::rightStickX() * -2.5 * d,
			0.0
		} );

		// Move the player with directional input.
		linalg::vec<float,2> move = { moveX, moveY };
		if( linalg::length( move ) > dead_zone ){
			move = linalg::normalize( move ) * 3.0f;
		}else{
			move = { 0.0f, 0.0f };
		}
		dungeon.moveAgent( dungeon.playerAgent, {
			move.x,
			jumpButtonDown ? 5.0f : 0.0f,
			move.y
		} );
		if( jumpButtonDown && dungeon.playerAgent->standing ){
			csPlaySound(
				PlayerUnderwater() ? "jump_underwater" : "jump",
				false
			);
		}
	}

	// Draw the dungeon.
	dungeon.pbr = std::stoi( settings.sections.get( "Misc" ).get( "PBR" ) );
	dungeon.frameBuf = &framebuffer;
	dungeon.draw( d );

	if( dungeon.pbr ){
		// Draw debug text.
		fgl::setPipeline( fgl::unlitPipeline );
		glDisable( GL_DEPTH_TEST );
		glEnable( GL_BLEND );
		glBlendFunc( GL_ZERO, GL_ONE_MINUS_SRC_COLOR );
		fgl::setFog( { 0.0, 0.0, 0.0, 0.0 } );
		std::string debug_text = "fps " + std::to_string( showFrames );
		/*
			"\nPosition X: " + std::to_string( dungeon.viewMat[3].x ) +
			"\nPosition Y: " + std::to_string( dungeon.viewMat[3].y ) +
			"\nPosition Z: " + std::to_string( dungeon.viewMat[3].z ) +
			"\nAngle X: " + std::to_string( dungeon.playerAgent->angle.x ) +
			"\nAngle Y: " + std::to_string( dungeon.playerAgent->angle.y ) +
			"\nAngle Z: " + std::to_string( dungeon.playerAgent->angle.z );
		*/
		fgl::drawText( debug_text, font_mono, 0, 100, 1.0 );
		glDisable( GL_BLEND );
	}
#endif
}

bool PlayerUnderwater(){
#ifdef __LIGHT__
	return false;
#else
	// TODO: Wave elevation.
	return dungeon.viewMat[3].y
		< dungeon.waterLevel + dungeon.waterWaveHeight * 0.5;
#endif
}

// Gracefully end the program.
void Quit(){
	world.unloadMap();
	#ifndef __LIGHT__
		dungeon.unloadMap();
	#endif
	#ifdef __EMSCRIPTEN__
		emscripten_websocket_deinitialize();
		health = 0;
		convo.screen = {
			"",
			"",
			"black",
			{},
			"",
			{},
			{}
		};
	#else
		#ifdef __STEAM__
			if( use_steam ){
				SteamAPI_Shutdown();
			}
		#endif
		cs_shutdown_context( csctx );
		fgl::end();
	#endif
}


Mode Type Size Ref File
100644 blob 98 227abf3bfa53b2530dcc74495da7bd0ccdcb0775 .gitignore
100644 blob 225 9b00c2c2e7b4f0c1e338fdead65f17ba0af089c1 COPYING
100755 blob 43 45aea818a4a3202b2467509f28a481cce08834d2 Confectioner.command
100644 blob 12084 c7186de6108ec7cc28ba672c691bcfcb1220f653 Makefile
100644 blob 2723 b5a3f573f076ef740ca742ec9598043732e10c0e README.md
040000 tree - f854ec266095e18d4bee80b61191f009190533d9 base
100755 blob 156 84cb1387849f2ca98e53e43536d00af2dfabf7d3 caveconfec
100755 blob 28 41b0ef285892c86306eaa269f366dd04cb633d21 caveconfec.bat
100644 blob 194779 d192eb66561ed3a920cfeda8a62247e50060024e confec.cpp
100644 blob 271414 377c477d5a6a99fc0c26335ac0d37b6dbb3218b4 gamecontrollerdb.txt
040000 tree - 6c17581ba04f2f775275d2ee5ca53e5e748c7f32 include
100755 blob 1041 dd7c0bd7d8a6b4aeff53142375240872735d42a0 libs.cpp
100755 blob 27581 8350a63e947e8a4a55608fd090d128fef7b969a1 micropather.cpp
100644 blob 141235 f54e2d2631a628876a631456c043b77da5db78bd openjdk.pem
100755 blob 8 e9a74187b02a27b165dfa4f93bf6f060376d0ee6 steam_appid.txt
Hints:
Before first commit, do not forget to setup your git environment:
git config --global user.name "your_name_here"
git config --global user.email "your@email_here"

Clone this repository using HTTP(S):
git clone https://rocketgit.com/user/mse/ConfectionerEngine

Clone this repository using ssh (do not forget to upload a key first):
git clone ssh://rocketgit@ssh.rocketgit.com/user/mse/ConfectionerEngine

Clone this repository using git:
git clone git://git.rocketgit.com/user/mse/ConfectionerEngine

You are allowed to anonymously push to this repository.
This means that your pushed commits will automatically be transformed into a merge request:
... clone the repository ...
... make some changes and some commits ...
git push origin main