
/*
	Game Framework
*/

include "io", "string", "fmt", "math";

global BIGNUM = 1e+32;


/* Utility functions */
function sign( x ){ return if( x == 0, 0, if( x < 0, -1, 1 ) ); }
function normalize_angle( x ){ x = x % ( M_PI * 2 ); return if( x < 0, x + M_PI*2, x ); }
function saturate( x ){ return if( x < 0, 0, if( x > 1, 1, x ) ); }
function smoothstep( x ){ return x * x * ( 3 - 2 * x ); }
function smoothlerp_oneway( t, a, b ){ if( b == a ) return 1.0; return smoothstep( saturate( ( t - a ) / ( b - a ) ) ); }
function smoothlerp_range( t, a, b, c, d ){ return smoothlerp_oneway( t, a, b ) * smoothlerp_oneway( t, d, c ); }


global GameFramework =
{
	Debug = false,
};


/*
	Bigger utility functions
*/

function Config_Load( file, defaults )
{
	out = null;
	pcall(function() use(file, defaults, out)
	{
		data = io_file_read( file );
		if( !data )
			return "failed to read the file";
		
		data = string_replace( data, ["\r\n","\r"], "\n" );
		
		entries = string_explode( data, "\n" );
		out = clone( defaults );
		foreach( eid, entry : entries )
		{
			keyval = string_explode( entry, "=" );
			if( keyval.size != 2 )
				return "error in format, entry #"$(eid+1);
				
			key = string_trim( keyval[0] );
			value = string_trim( keyval[1] );
			
			out[ key ] = value;
		}
	});
	return out;
}

/*
	GameLoop
	- create( fn, mindt, maxdt )
	+ !call( func )
*/

global GameLoop = {};

function GameLoop.create( fn, mindt, maxdt )
{
	lasttime = ftime();
	mindt ||= 0.0;
	maxdt ||= 1.0 / 15.0;
	stickydelta = 0.016;
	
	return function() use( fn, mindt, maxdt, lasttime, stickydelta )
	{
		curtime = ftime();
		delta = curtime - lasttime;
		lasttime = curtime;
		if( delta > 1.0/15.0 )
			delta = 1.0/15.0;
		
		if( GameFramework.Debug && abs( stickydelta - delta ) > 1.0 / 120.0 )
			println( "GFW DEBUG INFO: major deviation in delta time: "${ stickydelta = stickydelta, delta = delta } );
		stickydelta = stickydelta * 0.95 + delta * 0.05;
		
		fn( stickydelta, lasttime );
		
		if( mindt > 0 )
		{
			sleeptime = mindt - ( ftime() - lasttime );
			if( sleeptime > 0 )
		;//		sleep( sleeptime * 1000 );
		}
	};
}


/*
	InputKeys
	- create()
	> clear()
	> add_range( from, count )
	> add_list( list )
	> advance()
	> set( key, isdown )
	> get( key )
	> getprev( key )
	> is_pressed( key )
	> is_released( key )
*/

global InputKeys =
{
	keys = {},
	prevkeys = {},
};

function InputKeys.create()
{
	data =
	{
		keys = {},
		prevkeys = {},
	};
	return class( data, InputKeys );
}

function InputKeys.clear()
{
	this.keys = {};
	this.prevkeys = {};
}

function InputKeys.add_range( from, count )
{
	for( i = from; i < from + count; ++i )
	{
		this.keys[ i ] = 0.0;
		this.prevkeys[ i ] = 0.0;
	}
}

function InputKeys.add_list( list )
{
	foreach( i : list )
	{
		this.keys[ i ] = 0.0;
		this.prevkeys[ i ] = 0.0;
	}
}

function InputKeys.advance()
{
	foreach( i, v : this.keys )
		this.prevkeys[ i ] = v;
}

function InputKeys.set( key, amount ){ this.keys[ key ] = amount; }
function InputKeys.get( key ){ return this.keys[ key ]; }
function InputKeys.getprev( key ){ return this.keys[ key ]; }
function InputKeys.is_pressed( key ){ return this.keys[ key ] && !this.prevkeys[ key ]; }
function InputKeys.is_released( key ){ return !this.keys[ key ] && this.prevkeys[ key ]; }


/*
	ActionMap
	- create()
	+ add_action( action )
	+ remove_action( action )
	+ advance()
	+ set( action, amount )
	+ get( action )
	+ getprev( action )
	+ is_pressed( action )
	+ is_released( action )
*/

global ActionMap = {};

function ActionMap.create()
{
	data =
	{
		actions = {},
		prevactions = {},
		keys_to_actions = {},
	};
	return class( data, ActionMap );
}

function ActionMap.add_action( action )
{
	this.actions[ action ] = this.prevactions[ action ] = 0.0;
}

function ActionMap.remove_action( action )
{
	unset( this.actions, action );
	unset( this.prevactions, action );
}

function ActionMap.has_binding( action ){ return get_values( this.keys_to_actions ).find( action ) !== null; }
function ActionMap.add_binding( action, key ){ this.keys_to_actions[ key ] = action; }
function ActionMap.add_default_binding( action, key ){ if( !this.has_binding( action ) ) this.add_binding( action, key ); }
function ActionMap.remove_binding( action, key ){ if( this.keys_to_actions[ key ] == action ) unset( this.keys_to_actions, key ); }
function ActionMap.remove_all_bindings( action )
{
	foreach( k, a : this.keys_to_actions )
	{
		if( a == action )
		{
			this.keys_to_actions[ k ] = null;
			break;
		}
	}
	this.keys_to_actions = dict_filter( this.keys_to_actions );
}

function ActionMap.advance()
{
	foreach( i, v : this.actions )
		this.prevactions[ i ] = v;
}

function ActionMap.set( key, amount )
{
	if( isset( this.keys_to_actions, key ) )
		this.actions[ this.keys_to_actions[ key ] ] = toreal( amount );
}

function ActionMap.get( action ){ return this.actions[ action ]; }
function ActionMap.getprev( action ){ return this.prevactions[ action ]; }
function ActionMap.is_pressed( action ){ return this.actions[ action ] && !this.prevactions[ action ]; }
function ActionMap.is_released( action ){ return !this.actions[ action ] && this.prevactions[ action ]; }


/*
	EntityBuilder
	- clear()
	- scan( folder )
	- build( type, params )
*/

global EntityBuilder =
{
	entity_types = {},
};

function EntityBuilder._load_error( file, type, error )
{
	ERROR( string_format( "ENTITY BUILDER: error while loading file {1}, type {2} - {3}", file, type, error ) );
}

function EntityBuilder._build_error( type, error )
{
	ERROR( string_format( "ENTITY BUILDER: error while creating entity of type {1} - {2}", type, error ) );
}

function EntityBuilder.clear()
{
	this.entity_types = {};
}

function EntityBuilder._prepare( file, type, data )
{
	if( !isset( data, "create" ) ) this._load_error( file, type, "missing 'create' function" );
	if( !is_callable( data.create ) ) this._load_error( file, type, "'create' not callable" );
	if( !isset( data, "defaults" ) )
		data.defaults = {};
}

function EntityBuilder.scan( folder )
{
	foreach( is_real, item : io_dir( folder ) )
	{
		if( !is_real )
			continue;
		fullpath = folder $ "/" $ item;
		new_ents = eval_file( fullpath );
		foreach( new_ent_name, new_ent_data : new_ents )
		{
			this._prepare( fullpath, new_ent_name, new_ent_data );
		}
		this.entity_types = get_merged( this.entity_types, new_ents );
	}
}

function EntityBuilder.build( type, params )
{
	if( !isset( this.entity_types, type ) )
		return this._build_error( type, "type is not loaded" );
	
	info = this.entity_types[ type ];
	params = get_merged( info.defaults, params );
	return info.create( params );
}


/*
	GameWorld
	- create()
	+ add_entity( entity )
	+ remove_entity( id )
	+ remove_all_entities()
	+ get_entity_by_id( id )
	+ tick( delta )
*/

global GameWorld = {};

function GameWorld.create()
{
	data =
	{
		// DATA
		entities = {},
		in_tick = false,
		
		// CACHE
		z_order = [],
		z_indices = [],
		to_add = [],
		to_remove = [],
		
		// INDEXING
		auto_id = 1,
		
		// UTILITY
		efn = function(){},
	};
	return class( data, GameWorld );
}

function GameWorld._real_add_entity( entity )
{
	entity.id = this.auto_id++;
	if( !isset( entity, "z_index" ) )
		entity.z_index = 0;
	if( !isset( entity, "tick" ) ) entity.tick = this.efn;
	if( !isset( entity, "draw" ) ) entity.draw = this.efn;
	if( !isset( entity, "draw_ui" ) ) entity.draw_ui = this.efn;
	
	this.entities[ entity.id ] = entity;
	if( isset( entity, "added" ) ) entity.added( GameWorld );
}

function GameWorld._real_remove_entity( eid )
{
	if( isset( this.entities, eid ) )
	{
		if( isset( this.entities[ eid ], "removed" ) )
			this.entities[ eid ].removed( GameWorld );
		unset( this.entities, eid );
	}
}

function GameWorld._process_queues()
{
	foreach( eid : this.to_remove )
		this._real_remove_entity( eid );
	this.to_remove.clear();
	
	foreach( entity : this.to_add )
		this._real_add_entity( entity );
	this.to_add.clear();
}

function GameWorld.add_entity( entity )
{
	// expect to be used inside tick
	if( this.in_tick )
	{
		this.to_add.push( entity );
	}
	else
	{
		this._real_add_entity( entity );
	}
}

function GameWorld.remove_entity( eid )
{
	// expect to be used inside tick
	// cannot search to_add because there are no IDs assigned yet
	if( this.in_tick )
	{
		this.to_remove.push( eid );
	}
	else
	{
		this._real_remove_entity( eid );
	}
}

function GameWorld.remove_all_entities()
{
	// clone to avoid modification on iteration
	foreach( eid, : clone(this.entities) )
		this.remove_entity( eid );
}

function GameWorld.get_entity_by_id( eid )
{
	if( isset( this.entities, eid ) )
		return this.entities[ eid ];
	return null;
}

function GameWorld.tick( delta )
{
	this._process_queues();
	
	this.in_tick = true;
	
	this.z_indices.clear();
	this.z_order.clear();
	foreach( entity : this.entities )
	{
		if( delta > 0 )
			entity.tick( delta );
		this.z_indices.push( entity.z_index );
		this.z_order.push( entity );
	}
	
	this.z_order.sort_mapped( this.z_indices );
	
	foreach( entity : this.z_order )
		entity.draw();
	
	this.in_tick = false;
}

function GameWorld.tick_nodraw( delta )
{
	this._process_queues();
	
	this.in_tick = true;
	
	this.z_indices.clear();
	this.z_order.clear();
	foreach( entity : this.entities )
	{
		if( delta > 0 )
			entity.tick( delta );
		this.z_indices.push( entity.z_index );
		this.z_order.push( entity );
	}
	
	this.z_order.sort_mapped( this.z_indices );
	
	this.in_tick = false;
}

function GameWorld.send_message( eid, message, param )
{
	if( !isset( this.entities, eid ) )
		return false;
	ent = this.entities[ eid ];
	if( !isset( ent, message ) )
		return false;
	ent.(message)( param );
	return true;
}

function GameWorld.draw_ui( w, h )
{
	foreach( entity : this.z_order )
		entity.draw_ui( w, h );
}


/*
	AABBHash
	[aabb => min_x, min_y, max_x, max_y]
	- create( cell_width, cell_height )
	+ cleanup()
	+ add( aabb, item )
	+ remove( aabb, item )
	+ find_point( x, y )
	+ find_range( aabb )
	+ for_each( aabb, func, create = false )
	+ point_to_cell( x, y ) => (cx,cy)
	+ cell_id( x, y )
*/

global AABBHash = {};

function AABBHash.create( cell_width, cell_height )
{
	if( cell_width <= 0.0 || cell_height <= 0.0 )
		return ERROR( "AABBHash: cell size must be positive" );
	
	data =
	{
		cell_width = toreal( cell_width ),
		cell_height = toreal( cell_height ),
		hash = {},
	};
	
	return class( data, AABBHash );
}

function AABBHash.cleanup()
{
	// remove empty arrays from hash table
	this.hash = dict_filter( this.hash );
}

function AABBHash.add( min_x, min_y, max_x, max_y, item )
{
	this.for_each( min_x, min_y, max_x, max_y, function( cell ) use( item )
	{
		if( cell.find( item ) === null )
			cell.push( item );
	}, true );
}

function AABBHash.remove( min_x, min_y, max_x, max_y, item )
{
	this.for_each( min_x, min_y, max_x, max_y, function( cell ) use( item )
	{
		cell.remove( item );
	});
}

function AABBHash.find_point( x, y )
{
	(cx,cy) = this.point_to_cell( x, y );
	id = this.cell_id( cx, cy );
	if( isset( this.hash, id ) )
		return this.hash[ id ];
	return [];
}

function AABBHash.find_range( min_x, min_y, max_x, max_y )
{
	out = [];
	this.for_each( min_x, min_y, max_x, max_y, function( cell ) use( out )
	{
		out = get_concat( out, cell );
	});
	return out.unique();
}

function AABBHash.intersects( min_x, min_y, max_x, max_y )
{
	return min_x < this.max_x && this.min_x <= max_x
		&& min_y < this.max_y && this.min_y <= max_y;
}

function AABBHash.for_each( min_x, min_y, max_x, max_y, func, create )
{
	create ||= false;
	(mincx,mincy) = this.point_to_cell( min_x, min_y );
	(maxcx,maxcy) = this.point_to_cell( max_x, max_y );
	for( y = mincy; y <= maxcy; ++y )
	{
		for( x = mincx; x <= maxcx; ++x )
		{
			id = this.cell_id( x, y );
			if( isset( this.hash, id ) )
				func( this.hash[ id ] );
			else if( create )
			{
				arr = [];
				func( arr );
				this.hash[ id ] = arr;
			}
		}
	}
}

function AABBHash.point_to_cell( x, y )
{
	x /= this.cell_width;
	y /= this.cell_height;
	x = floor( x );
	y = floor( y );
	return x, y;
}

function AABBHash.cell_id( x, y )
{
//	return x$'|'$y;
	return fmt_pack( "ll", x, y );
}


/*
	AStarPathfinder
	- create( links, cost_function = <1>, data_set = null )
	+ find_path( from_id, to_id )
*/

global AStarPathfinder = {};

function AStarPathfinder.create( links, cost_function, data_set )
{
	cost_function ||= function(){ return 1; };
	node_links = {};
	node_pair_links = {};
	foreach( link : links )
	{
		n0 = link[0];
		n1 = link[1];
		if( !isset( node_links, n0 ) )
			node_links[ n0 ] = [ link ];
		else
			node_links[ n0 ].push( link );
		if( !isset( node_links, n1 ) )
			node_links[ n1 ] = [ link ];
		else
			node_links[ n1 ].push( link );
		node_pair_links[ fmt_pack( "ll", n0, n1 ) ] = link;
		node_pair_links[ fmt_pack( "ll", n1, n0 ) ] = link;
	}
	
	data =
	{
		links = links,
		cost_function = cost_function,
		data_set = data_set,
		node_links = node_links,
		node_pair_links = node_pair_links,
	};
	
	return class( data, AStarPathfinder );
}

function AStarPathfinder.find_path( from_id, to_id, from_item, to_item )
{
	if( from_id == to_id )
		return [];
	
	cost_function = this.cost_function;
	data_set = this.data_set;
	
	closed_set = {};
	open_set = {}; open_set[ from_id ] = true;
	came_from = {};
	
	g_score = {};
	f_score = {};
	
	g_score[ from_id ] = 0;
	f_score[ from_id ] = cost_function( from_item, to_item );
	
	while( open_set )
	{
		// current = item from f_score with lowest cost
		lowest = BIGNUM;
		current = -1;
		foreach( fitem ,: open_set )
		{
			score = f_score[ fitem ];
			if( score < lowest )
			{
				current = fitem;
				lowest = score;
			}
		}
		
		// reached destination
		if( current == to_id )
		{
			prev = to_id;
			out = [];
			for(;;)
			{
				next = came_from[ prev ];
				out.push( this.node_pair_links[ fmt_pack( "ll", prev, next ) ] );
				if( next == from_id )
					break;
				prev = next;
			}
			return out.reverse();
		}
		
		// mark node as checked
		unset( open_set, current );
		closed_set[ current ] = true;
		
		// check the neighbors
		if( isset( this.node_links, current ) )
		{
			foreach( neighbor_obj : this.node_links[ current ] )
			{
				neighbor = neighbor_obj[ neighbor_obj[0] == current ];
				
				pred_g_score = g_score[ current ] + cost_function( if( current == from_id, from_item, data_set[ current ] ), data_set[ neighbor ] );
				pred_f_score = pred_g_score + cost_function( data_set[ neighbor ], to_item );
				
				if( isset( closed_set, neighbor ) && pred_f_score >= f_score[ neighbor ] )
					continue;
				
				if( !isset( open_set, neighbor ) || pred_f_score < f_score[ neighbor ] )
				{
					came_from[ neighbor ] = current;
					g_score[ neighbor ] = pred_g_score;
					f_score[ neighbor ] = pred_f_score;
					open_set[ neighbor ] = true;
				}
			}
		}
	}
	
	return null;
}


/*
	NavMeshPathfinder
	- create( polygons, links )
	+ find_path( from_x, from_y, to_x, to_y, char_width = 0 )
	- poly_to_aabb( poly ) => (min_x,min_y,max_x,max_y)
	+ find_point_polygon( x, y )
	- cost_function( p1, p2, data(centers) )
	
	Notes:
	- link format: [ p1, p2, p1v1, p1v2, p2v1, p2v2, point1, point2 ]
	- find_path returns a list of points, each between its link line segment endpoints ..
	- .. proper corner handling can be done by looking ahead, forming a triangle and checking where the path will turn ..
	- .. ending point (to_x,to_y) can be added manually if necessary - after all, it is passed to the function
*/

global NavMeshPathfinder = {};

function NavMeshPathfinder.create( polygons, links )
{
	centers = [].reserve( polygons.size );
	foreach( poly : polygons )
	{
		center = vec2(0,0);
		foreach( vertex : poly )
			center += vertex;
		center /= poly.size;
		centers.push( center );
	}
	
	bbhash = AABBHash.create( 32, 32 );
	foreach( pid, poly : polygons )
	{
		(min_x,min_y,max_x,max_y) = NavMeshPathfinder.poly_to_aabb( poly );
		bbhash.add( min_x, min_y, max_x, max_y, pid );
	}
	
	astar = AStarPathfinder.create( links, NavMeshPathfinder.cost_function, centers );
	
	data =
	{
		bbhash = bbhash,
		pathfinder = astar,
		polygons = polygons,
		centers = centers,
		links = links,
	};
	
	return class( data, NavMeshPathfinder );
}

function NavMeshPathfinder.find_path( from_x, from_y, to_x, to_y, char_radius )
{
	from_id = this.find_point_polygon( from_x, from_y );
	to_id = this.find_point_polygon( to_x, to_y );
	
	if( from_id === null || to_id === null )
		return null;
	
	from_v2 = vec2(from_x,from_y);
	to_v2 = vec2(to_x,to_y);
	asp = this.pathfinder.find_path( from_id, to_id, from_v2, to_v2 );
	if( asp === null )
		return null;
	
	char_radius ||= 0;
	out = [ from_v2 ];
	foreach( pl : asp )
	{
		out.push( ( pl[ 6 ] + pl[ 7 ] ) / 2 );
	}
	out.push( to_v2 );
	
	iters = 3;
	last = out.size - 2;
	while( iters --> 0 )
	{
		for( i = 1; i <= last; ++i )
		{
			pi = i - 1;
			
			// get link limits
			lep1 = asp[pi][6];
			lep2 = asp[pi][7];
			
			t = ( lep2 - lep1 ).normalized;
			n = t.perp;
			
			dpn = vec2_dot( lep1, n );
			dp1 = vec2_dot( lep1, t );
			dp2 = vec2_dot( lep2, t );
			dcp = vec2_dot( out[i], t );
			dcpPrev = vec2_dot( out[i-1], t );
			dcpNext = vec2_dot( out[i+1], t );
			ndpPrev = vec2_dot( out[i-1], n );
			ndpCurr = dpn;
			ndpNext = vec2_dot( out[i+1], n );
			
			// average and clamp
			sum = abs( ndpPrev - ndpCurr ) + abs( ndpNext - ndpCurr );
			factor = if( sum < 0.01, 0.5, abs( ndpPrev - ndpCurr ) / sum );
			dcp = dcpPrev * (1-factor) + dcpNext * factor;
			if( dcp < dp1 ) dcp = dp1;
			if( dcp > dp2 ) dcp = dp2;
			
			// pull out of walls if necessary
			fp = t * dcp + n * dpn;
			if( dcp <= dp1 + asp[pi][8].length * char_radius ) fp = asp[pi][6] + asp[pi][8] * char_radius;
			if( dcp >= dp2 - asp[pi][9].length * char_radius ) fp = asp[pi][7] + asp[pi][9] * char_radius;
			
			// push back
			out[i] = fp;
		}
	}
	out.pop();
	out.shift();
	
	return out;
}

function NavMeshPathfinder.poly_to_aabb( poly )
{
	min_x = BIGNUM;
	max_x = -BIGNUM;
	min_y = BIGNUM;
	max_y = -BIGNUM;
	foreach( v : poly )
	{
		x = v.x;
		y = v.y;
		if( x < min_x ) min_x = x;
		if( x > max_x ) max_x = x;
		if( y < min_y ) min_y = y;
		if( y > max_y ) max_y = y;
	}
	return min_x, min_y, max_x, max_y;
}

function NavMeshPathfinder.find_point_polygon( x, y )
{
	polys = this.bbhash.find_point( x, y );
	if( polys )
	{
		// find overlap
		foreach( pid : polys )
		{
			poly = this.polygons[ pid ];
			if( this.point_in_polygon( poly, x, y ) )
				return pid;
		}
	}
	return null;
}

function NavMeshPathfinder.point_in_polygon( poly, x, y )
{
	if( poly.size < 3 )
		return false;
	tp = vec2(x,y);
	pp = poly.last;
	foreach( cp : poly )
	{
		enorm = ( cp - pp ).perp2.normalized;
		if( vec2_dot( enorm, tp ) > vec2_dot( enorm, cp ) + 0.001 )
			return false;
		pp = cp;
	}
	return true;
}

function NavMeshPathfinder.cost_function( p1, p2 )
{
	return ( p1 - p2 ).length;
}


/*
	AIInfo
	- create()
	+ put_entity( id, group, pos )
	+ remove_entity( id )
	+ emit_sound( type, timeout, radius, pos )
	+ gather_visible( from, func )
	+ gather_heard( from )
	
	SOUND [ pos, type, timeout, radius ]
	ENTITY [ pos, group ]
*/

global AIInfo = {};

function AIInfo.create()
{
	data =
	{
		entities = {},
		sounds = [],
	};
	
	return class( data, AIInfo );
}

function AIInfo.put_entity( id, group, pos )
{
	this.entities[ id ] = [ pos, group ];
}

function AIInfo.remove_entity( id )
{
	unset( this.entities, id );
}

function AIInfo.emit_sound( pos, type, timeout, radius, id )
{
	this.sounds.push([ pos, type, timeout, radius, id ]);
}

function AIInfo.gather_visible( from, func )
{
	visible = {};
	foreach( eid, entity : this.entities )
	{
		factor = func( eid, entity, from );
		if( factor > 0 )
			visible[ eid ] = get_concat( [ factor ], entity );
	}
	return visible;
}

function AIInfo.gather_heard( from, mindist, ignore_ids )
{
	heard = [].reserve( sqrt( this.sounds.size ) );
	foreach( sound : this.sounds )
	{
		dist = ( sound[0] - from ).length;
		if( dist < mindist ) continue;
		if( dist > sound[3] ) continue;
		if( ignore_ids && ignore_ids.find( sound[4] ) !== null ){ continue; }
		power = 1 - dist / sound[3];
		heard.push([
			power,
			sound[0],
			sound[1],
			sound[2],
			sound[3],
			sound[4]
		]);
	}
	return heard;
}

function AIInfo.tick( delta )
{
	foreach( sid, sound : this.sounds )
	{
		if( ( sound[1] -= delta ) < 0 )
		{
			this.sounds[ sid ] = null;
			continue;
		}
	}
	this.sounds = array_filter( this.sounds );
}
