Motion, Game Design, and Art: Rhymeheart Devlog [#2]

Devlog #1

Hi, all.

It’s Friday! I missed yesterday’s devlog, so here it is.

Over the last week, I added motion and collisions. I am mostly happy with the way the physics works, with the exception of non-rectangular collisions. Even after implementing angle-sweep collision reconciliation, it’s very easy to get stuck on elliptical or otherwise irregular collision masks. Unsure of a fix, I’ve put it off for the future when it will matter more. I also drew a tree sprite.

Motion and collision demonstration.

I started work on implementing combat by creating little goblins with axes that can chase you around, as shown in the above gif. Combat, like I mentioned in the previous part, will be turn-based. So, the idea was that when you bump into an enemy, it spawns a grid/board over the world, and becomes a battle that other players can watch in real-time, like the way combat works in Wizard101. I haven’t actually started on combat yet, I mostly focused on adding more art to the game.

Game Design

I began work on designing some of the items and skills that I wanted featured in the game. The ones I chose to focus on first were mining and smithing. I first came up with a list of all the metals I wanted in the game, how to make them, and what they would look like. Then, I set out to draw ores for all of them, item icons for those ores, and finally item icons for the smelted bars for each metal. The only thing I did not draw was carbon, which is needed for steel.

All ores, with the exception of carbon.
All bars / ingots, generally in order of strength.
Level Range Metal Type(s) Smelting Recipe
1 – 10BronzeCopper + Tin (2:1)
11 – 20Iron
21 – 30SteelIron + Carbon (4:1)
31 – 40ElectrumSilver + Gold (1:1)
41 – 50Platinum
51 – 60Titanium
61 – 70Cobalt
71 – 80Laudium
81 – 90Venutian, Erisian
91 – 100Astril, Essil

Level range is the expected range of levels that players might use weapons and armor made out of that material. I might not actually use levels in the game, but it’s a rough idea of the order of appearance and the strength of each metal. If a metal has a specified smelting recipe, it means it is created out of bars/ingots of other metals, or other ingredients such as carbon. I might add other types of materials that could be used in smithing, such as obsidian glass, gemstones, etc. in the future, but this is plenty for now.

Venutian and Erisian will be complements, where players will likely choose to focus one or the other. They will probably have distinct characteristics that make you want to choose one. The same goes for Astril and Essil. They are both equally strong but will have varied characteristics.

Rendering

You might have noticed in the gif above that the goblins are able to render before or after each other and the trees. This is because I created a renderer object that handles the draw order of entities manually. I used a clever trick to encapsulate the creation and destruction of entities so the renderer can know about them without ever needing to couple the logic. You can find a snippet of that clever trick called Event Bus on the GML page. I also added an “EntityMoved” event that the renderer can listen to, so it can update the render priority of only the entities that moved each frame. This greatly optimizes the rendering speed. One last optimization I made was to pre-sort the entities once in the Pre-Draw event by duplicating the render priority, popping all of its contents into an array, and persisting that array through each draw event type. Now, sorting only happens once per frame.

I actually initially implemented a Binary Heap in the game using structs, but found it difficult to create a custom Priority Queue structure on top of it that had all the features of the built-in priority queues, so I just used those.

Next Time

Over the next week, I will try to get more of the core skills into the game, at least the art for them. I will also try to design the mini-games each skill will use. I think the next one I will do art for is Fishing. I might also start working on actually implementing combat. See you next week.

New Game: Rhymeheart Devlog [#1]

Hi, all.

I am beginning a new project called Rhymeheart (working title, subject to change). Here is a screenshot of some art I did. There’s a lot more art than this, however I can’t showcase it off just yet.

The idea for the game is a multiplayer open-world RPG. Combat is turn-based on a grid. Attacking, dodging/blocking, and possibly movement on the grid will each use different mini-games (maybe rhythm games, haven’t decided yet). Skills such as smithing, mining, woodcutting, fishing, fletching, etc. may also have mini-games to determine the quality of produced items.

No gameplay has been created yet. The game is currently just art assets and some basic movement. I will make a post every Thursday night (hopefully) with changes. Feel free to leave a comment with suggestions, ideas, questions, or anything else.

View to GUI Conversion

Credit: Gizmo199

function view_to_gui_x(camera, x)
{
	return ((x - camera_get_view_x(camera)) / camera_get_view_width(camera)) * display_get_gui_width();
}

function view_to_gui_y(camera, y)
{
	return ((y - camera_get_view_y(camera)) / camera_get_view_height(camera)) * display_get_gui_height();
}

Usage

These are simple scripts that convert the given x/y coordinates in a room to the appropriate x/y coordinates on the GUI layer when using views. These scripts are essential, especially when dealing with shader effects that need to be drawn on the GUI layer (eg. Motion blur, Shockwaves, etc.). They are also particularly good for rendering elements such as text boxes or input prompts to the screen on the GUI layer where needed.

Optimization Tip

#macro inline gml_pragma("forceinline")

Add this somewhere in your game, and you will be able to simply type inline; in a function to make it faster to call in YYC mode. I would personally always inline simple helper functions like the two listed above.

Fancy Circular Healthbar

Written by me (Anixias).

function draw_circle_hollow(x, y, radius, width)
{
	draw_circle_hollow_quality(x, y, radius, 0, width, 64);
}

function draw_circle_hollow_quality(x, y, radius, offset, width, quality)
{
	draw_primitive_begin(pr_trianglelist);
	for(var i = offset; i < 360 + offset; i += 360 / quality)
	{
		var r = max(0, radius - width);
		var next_i = (i + (360 / quality));
			
		var inner_x = x + lengthdir_x(r, i);
		var inner_y = y + lengthdir_y(r, i);
		var outer_x = x + lengthdir_x(radius, i);
		var outer_y = y + lengthdir_y(radius, i);
			
		var next_inner_x = x + lengthdir_x(r, next_i);
		var next_inner_y = y + lengthdir_y(r, next_i);
		var next_outer_x = x + lengthdir_x(radius, next_i);
		var next_outer_y = y + lengthdir_y(radius, next_i);
			
		// First triangle
		draw_vertex(inner_x, inner_y);				// inner
		draw_vertex(outer_x, outer_y);				// outer
		draw_vertex(next_inner_x, next_inner_y);	// next inner
			
		// Second triangle
		draw_vertex(next_inner_x, next_inner_y);	// next inner
		draw_vertex(outer_x, outer_y);				// outer
		draw_vertex(next_outer_x, next_outer_y);	// next outer
	}
	draw_primitive_end();
}

function draw_healthbar_circular(x, y, radius, amount, backcol, mincol, maxcol, offset, direction, showback, showborder, width)
{
	draw_healthbar_circular_quality(x, y, radius, amount, backcol, mincol, maxcol, offset, direction, showback, showborder, width, 64);
}

function draw_healthbar_circular_quality(x, y, radius, amount, backcol, mincol, maxcol, offset, direction, showback, showborder, width, quality)
{
	if (direction < 0) offset += 180;
	
	quality = max(3, quality);
	
	// Using primitives
	var start_color = draw_get_color();
	
	// Back
	if (showback)
	{
		// Border
		if (showborder)
		{
			draw_set_color(c_black);
			draw_circle_hollow_quality(x + 1, y    , radius, offset, width, quality);
			draw_circle_hollow_quality(x - 1, y    , radius, offset, width, quality);
			draw_circle_hollow_quality(x    , y + 1, radius, offset, width, quality);
			draw_circle_hollow_quality(x    , y - 1, radius, offset, width, quality);
		}
		
		// Back
		draw_set_color(backcol);
		draw_circle_hollow_quality(x, y, radius, offset, width, quality);
	}
	
	// Lerp color
	var r1, g1, b1;
	var r2, g2, b2;
	var r3, g3, b3;
	
	r1 = (mincol & 0x0000FF);
	g1 = (mincol & 0x00FF00) >> 8;
	b1 = (mincol & 0xFF0000) >> 16;
	
	r2 = (maxcol & 0x0000FF);
	g2 = (maxcol & 0x00FF00) >> 8;
	b2 = (maxcol & 0xFF0000) >> 16;
	
	var amt = clamp(amount / 100, 0, 1);
	r3 = lerp(r1, r2, amt);
	g3 = lerp(g1, g2, amt);
	b3 = lerp(b1, b2, amt);
	
	// Border
	if (showborder)
	{
		if (!showback)
		{
			var _x = x;
			var _y = y;
			
			draw_set_color(c_black);
			for(y = _y - 1; y <= _y + 1; y += 2)
			{
				for(x = _x - 1; x <= _x + 1; x += 2)
				{
					draw_primitive_begin(pr_trianglelist);
					for(var i = offset; i < 360 * amt + offset; i += 360 / quality)
					{
						var r = max(0, radius - width);
						var this_i = direction < 0 ? -i : i;
						var next_i = (i + (360 / quality)) * (direction < 0 ? -1 : 1);
						
						var inner_x = x + lengthdir_x(r, this_i);
						var inner_y = y + lengthdir_y(r, this_i);
						var outer_x = x + lengthdir_x(radius, this_i);
						var outer_y = y + lengthdir_y(radius, this_i);
						
						var next_inner_x = x + lengthdir_x(r, next_i);
						var next_inner_y = y + lengthdir_y(r, next_i);
						var next_outer_x = x + lengthdir_x(radius, next_i);
						var next_outer_y = y + lengthdir_y(radius, next_i);
						
						// First triangle
						draw_vertex(inner_x, inner_y);				// inner
						draw_vertex(outer_x, outer_y);				// outer
						draw_vertex(next_inner_x, next_inner_y);	// next inner
						
						// Second triangle
						draw_vertex(next_inner_x, next_inner_y);	// next inner
						draw_vertex(outer_x, outer_y);				// outer
						draw_vertex(next_outer_x, next_outer_y);	// next outer
					}
					draw_primitive_end();
				}
			}
			x = _x;
			y = _y;
		}
		else
		{
			draw_set_color(c_black);
			
			var border_offset = 4;
			var progress = 0;
			
			draw_primitive_begin(pr_trianglelist);
			for(var i = offset; amt > 0 && i <= 360 * ceil(amt * 1000) / 1000 + offset; i += 360 / quality)
			{
				var r = max(0, radius - width);
				var this_i = direction < 0 ? -(i - border_offset) : i - border_offset;
				var next_i = (i + (360 / quality) + border_offset) * (direction < 0 ? -1 : 1);
				
				var inner_x = x + lengthdir_x(r, this_i);
				var inner_y = y + lengthdir_y(r, this_i);
				var outer_x = x + lengthdir_x(radius, this_i);
				var outer_y = y + lengthdir_y(radius, this_i);
				
				var next_inner_x = x + lengthdir_x(r, next_i);
				var next_inner_y = y + lengthdir_y(r, next_i);
				var next_outer_x = x + lengthdir_x(radius, next_i);
				var next_outer_y = y + lengthdir_y(radius, next_i);
				
				if (i + (360 / quality) > 360 * ceil(amt * 1000) / 1000 + offset)
				{
					var amt_within = (amt - progress) / (1 / quality);
					next_inner_x = lerp(inner_x, next_inner_x, amt_within);
					next_inner_y = lerp(inner_y, next_inner_y, amt_within);
					next_outer_x = lerp(outer_x, next_outer_x, amt_within);
					next_outer_y = lerp(outer_y, next_outer_y, amt_within);
				}
				
				// First triangle
				draw_vertex(inner_x, inner_y);				// inner
				draw_vertex(outer_x, outer_y);				// outer
				draw_vertex(next_inner_x, next_inner_y);	// next inner
				
				// Second triangle
				draw_vertex(next_inner_x, next_inner_y);	// next inner
				draw_vertex(outer_x, outer_y);				// outer
				draw_vertex(next_outer_x, next_outer_y);	// next outer
				
				progress += 1 / quality;
			}
			draw_primitive_end();
		}
	}
	
	var progress = 0;
	
	draw_set_color(r3 | (g3 << 8) | (b3 << 16));
	draw_primitive_begin(pr_trianglelist);
	for(var i = offset; amt > 0 && i <= 360 * ceil(amt * 1000) / 1000 + offset; i += 360 / quality)
	{
		var r = max(0, radius - width);
		var this_i = direction < 0 ? -i : i;
		var next_i = (i + (360 / quality)) * (direction < 0 ? -1 : 1);
		
		var inner_x = x + lengthdir_x(r, this_i);
		var inner_y = y + lengthdir_y(r, this_i);
		var outer_x = x + lengthdir_x(radius, this_i);
		var outer_y = y + lengthdir_y(radius, this_i);
		
		var next_inner_x = x + lengthdir_x(r, next_i);
		var next_inner_y = y + lengthdir_y(r, next_i);
		var next_outer_x = x + lengthdir_x(radius, next_i);
		var next_outer_y = y + lengthdir_y(radius, next_i);
		
		// If this is the last iteration before the loop stops
		if (i + (360 / quality) > 360 * ceil(amt * 1000) / 1000 + offset)
		{
			var amt_within = (amt - progress) / (1 / quality);
			next_inner_x = lerp(inner_x, next_inner_x, amt_within);
			next_inner_y = lerp(inner_y, next_inner_y, amt_within);
			next_outer_x = lerp(outer_x, next_outer_x, amt_within);
			next_outer_y = lerp(outer_y, next_outer_y, amt_within);
		}
		
		// First triangle
		draw_vertex(inner_x, inner_y);				// inner
		draw_vertex(outer_x, outer_y);				// outer
		draw_vertex(next_inner_x, next_inner_y);	// next inner
		
		// Second triangle
		draw_vertex(next_inner_x, next_inner_y);	// next inner
		draw_vertex(outer_x, outer_y);				// outer
		draw_vertex(next_outer_x, next_outer_y);	// next outer
		
		progress += 1 / quality;
	}
	draw_primitive_end();
	
	draw_set_color(start_color);
}

Notes

This can be easily modified to use textures, since it uses primitives to render. The border rendering is slightly wrong on the partial circle (the one that shows the amount), but it’s good enough for my purposes. You can use draw_healthbar_circular_quality to actually render non-circular shapes (quality of 3 is a triangle, quality of 4 is either a diamond or rectangle based on the offset you provide, quality of 5 is a pentagon, quality of 6 is a hexagon, etc.).

Modular Multiplication

Written by me (Anixias).

function mulmod(a, b, modulo)
{
	var res = 0;
	a %= modulo;
	while(b > 0)
	{
		if (b % 2 == 1)
		{
			res = (res + a) % modulo;
		}
		
		a = (a * 2) % modulo;
		
		b = b >> 1;
	}
	
	return res % modulo;
}

Usage

This function will multiply two numbers, a and b, and ensure the product doesn’t overflow past modulo. It avoids actually multiplying a by b so GMS2 doesn’t lose precision. After several tests with large 32-bit integers, it is perfectly accurate, whereas using normal operations (literally using (a * b) % 0x100000000) produced slightly inaccurate results. Here is a simple function to multiply two unsigned 32-bit integers, ensuring the product is an unsigned 32-bit integer (via overflow):

function multiply_u32(a, b)
{
	return mulmod(a, b, 0x100000000);
}

Circular Healthbar

Credit: Gizmo199

function draw_healthbar_circular(_x, _y, _radius, _amount, _backcol, _mincol, _maxcol, _direction, _showback, _thickness)
{
	// Get our color
	var _r1, _r2, _g1, _g2, _b1, _b2;
	
	_r1 = color_get_red(_maxcol);
	_g1 = color_get_green(_maxcol);
	_b1 = color_get_blue(_maxcol);
	
	_r2 = color_get_red(_mincol);
	_g2 = color_get_green(_mincol);
	_b2 = color_get_blue(_mincol);
	
	// Calculate the difference between each color channel
	var _rVal = _r2 + (((_r1 - _r2) * _amount) / 100);
	var _gVal = _g2 + (((_g1 - _g2) * _amount) / 100);
	var _bVal = _b2 + (((_b1 - _b2) * _amount) / 100);
	
	var _col = make_color_rgb(_rVal, _gVal, _bVal);
	
	// Draw our circular healtbar
	draw_set_color(_backcol);
	
	for(var i = 0; i < 360; i++)
	{
		// get rotation
		var _ldx = lengthdir_x(_radius, _direction + i);
		var _ldy = lengthdir_y(_radius, _direction + i);
		
		var _ldx2 = lengthdir_x(_radius + _thickness, _direction + i);
		var _ldy2 = lengthdir_y(_radius + _thickness, _direction + i);
		
		// Back ring
		if (_showback)
		{
			draw_line(_x + _ldx, _y + _ldy, _x + _ldx2, _y + _ldy2);
		}
		
		// Default is set to 100 as the maximum percentage to calculate
		var _percent = 100;
		var _val = ( _amount * 360 ) / _percent;
		
		// health ring
		if ((360 - _val) <= i)
		{
			draw_set_color(_col);
			draw_line(_x + _ldx, _y + _ldy, _x + _ldx2, _y + _ldy2);
		}
	}
}

Usage

draw_healthbar_circular(x, y, radius, amount, backcol, mincol, maxcol, direction, showback, showborder, thickness)


For drawing general circular healthbars, meters, etc.

Remapping a Range

Written by me (Anixias).

function map_range(value, srcmin, srcmax, destmin, destmax)
{
	return destmin + (destmax - destmin) * ((value - srcmin) / (srcmax - srcmin));
}

Usage

You can use this function to map one range to another. For example, say you want to convert a point in the view to a point in the GUI. You could call something like (pseudo-code):

point_x_in_gui = map_range(point_x_in_view, view_x, view_x + view_width, 0, display_get_gui_width());
point_y_in_gui = map_range(point_y_in_view, view_y, view_y + view_height, 0, display_get_gui_height());

Smooth Wrapping Lerp

Written by me (Anixias).

function wrap(val, minval, maxval)
{
	while(val < minval) val += maxval - minval;
	while(val >= maxval) val -= maxval - minval;
	
	return val;
}

function lerp_wrap(src, dest, amt, minval, maxval)
{
	if (dest > src)
	{
		if (abs(dest - src) >= abs((dest - (maxval - minval)) - src))
		{
			dest -= (maxval - minval);
		}
	}
	else if (dest < src)
	{
		if (abs(dest - src) >= abs((dest + (maxval - minval)) - src))
		{
			dest += (maxval - minval);
		}
	}
	else return wrap(dest, minval, maxval);
	
	var val = lerp(src, dest, amt);
	return wrap(val, minval, maxval);
}

Usage

You can use this to lerp from one value to another, but it takes into account that there is a wrapping range. For example, say you want to change image_angle to lerp towards some direction D every frame, but it should rotate in the direction closest to the goal. Let’s assume image_angle is 350 degrees, and D is 15 degrees. Obviously, we should increase image_angle, since 15 is the same as 375, and going up from 350 to 375 is faster than going down from 350 to 15. This script takes that into account. You can achieve this by calling lerp_wrap(image_angle, D, 0.1, 0, 360) in the Step Event. 0.1 here can be any number you want, from 0 to 1.

Note: This script works in the range [minval, maxval). AKA, it can equal minval, but it will never equal maxval.

Positional Pseudorandom Noise

Written by me (Anixias). Warning: This does not work well, and you should instead look up squirrel3 or see the GDC video on YouTube on noise to replace RNGs.

function pseudorandom_position(x, y)
{
	var z = ((x + 10) * 6587 + (y + 43) * 9811) % 1;
	
	var xt = (dcos(360 * 8 * x) + 1) / 2;
	var yt = (dsin(360 * 8 * y) + 1) / 2;
	var zt = abs(dsin(360 * 8 * z)) % 1;
	
	return (xt + yt + zt) % 1;
}

Usage

Call this function on a position (integer coordinates) to get a seemingly random number from 0 – 1 (never equals 1). You can use this to, without messing with the random seed and affecting subsequent random calls, render tiles on a grid with random variations. Multiply the returned value by the number of subimages (in a grass tile, for example), then floor that value to get a “random” subimage to use for rendering.

Surface Stack

Written by me (Anixias).

global.surface_stack = ds_stack_create();
global.surface_is_set = false;

/// @arg id
function surface_set(surf)
{
	if (global.surface_is_set) surface_reset_target();
	
	surface_set_target(surf);
	ds_stack_push(global.surface_stack, surf);
	global.surface_is_set = true;
}

function surface_reset()
{
	if (global.surface_is_set)
	{
		surface_reset_target();
		global.surface_is_set = false;
	}
	ds_stack_pop(global.surface_stack);
	if (!ds_stack_empty(global.surface_stack))
	{
		var surf = ds_stack_top(global.surface_stack);
		if (surface_exists(surf))
		{
			surface_set_target(surf);
			global.surface_is_set = true;
		}
		else show_error("Error: Reset surface didn't exist", true);
	}
}

Usage

Simply replace surface_set_target and surface_reset_target throughout your code with surface_set and surface_reset, respectively. This allows you to nest surface rendering. I wrote it because I needed a global UI surface, but I use surfaces throughout the UI. This allows me to use surface rendering wherever I need it without worrying about resetting the surface target every time I need to render to a surface, since I can’t know at runtime whether I’m currently rendering to a surface or not.

You can also use global.surface_is_set to know if you are currently rendering to a surface, and ds_stack_top(global.surface_stack) to get the id of the surface you are currently rendering to.