ID:1816091
 
Welcome again to another provocatively titled Snippet Sunday. Today we're going to be talking about a problem that's all too common with BYOND games: Poor interface implementations. Specifically, we're going to be talking about map-focused interfaces and how to calculate ideal viewport sizes for a user's given resolution.

Stretch to fit and why you really should never use it

This is the main problem I see with a lot of BYOND games. In the best case scenario, it causes graphics to look stretched an creates pixel artifacts in the outline of objects. In the worst-case scenario, on certain graphics cards, it makes the entire viewport blurry because certain graphics cards don't seem to kick into nearest-neighbor interpolation.

You can see the effect of this problem in this thread: http://www.byond.com/ forum/?post=1673451&hl=blurry#comment11919097

To avoid using this feature, you need to understand a few basic values and calculations.

icon_size: your world's icon_size determines the dimensions of a single tile in your world. This will influence the final dimensions of your view_size. It uses the format: "[TILE_WIDTH]x[TILE_HEIGHT]", or if both width and height are the same, just a single number in pixels.

view: your world/client's view size determines the number of tiles that are shown in the viewport at any one time. This does not have to perfectly match the size of your map element, as will be explained later. View is formatted as a single integer number of tiles, or a text string "[VIEW_WIDTH]x[VIEW_HEIGHT]".

map dimensions: The dimensions of the map element will be set in pixels. You can use this value to determine the ideal view size.

Inner offset: The inner offset dimension is a pixel value that's calculated by determining how far beyond the edge of the map element's dimensions the viewport will extend. This is a useful value for later, as it will allow you to calculate the position of HUD elements that should be displayed according to the edge of the screen.

Now, let's run over map elements in the interface and explain a few of their properties before we get started explaining some calculations you will need to make your game not look like crap within the viewport:



When you are first setting up your project, you need to know the icon_size for your project. Never use stretch to fit. Period. Just don't. Pixel art needs to use specific scaling ratios with integer constraints to look good, so you need to make certain you use the correct constraints. Explicitly declare the icon_size for your project in this box. That's all you need to do.

Now, let's talk about ideal resolutions a bit.

In order to calculate your ideal resolution, you need to understand common screen resolutions. Check out the steam hardware survey:

http://store.steampowered.com/hwsurvey/

The three most common resolutions are (26.43% marketshare) 1366x768 (widescreen laptop), (34.02% marketshare) 1920x1080 (1080p monitor, this is the standard these days), and (7.55% marketshare) 1600x900 (high definition widescreen laptop).

27.82% of all steam users have a 1920x1080 desktop resolution with two monitors. The largest majority of multi-monitor displays (27.8%) use this setup. No other setup is even close to the popularity of two 1080p monitors.

Now, it's also good to remember that most BYOND users are using low-end windows 8 laptops and extremely outdated desktop machines. It's good to keep this in mind, but do not tie your high-end users to your low-end users. You should not be targeting your game to the lowest common denominator, because in the end it's going to drive your power-users away from your product.

Now that that's been said, let's talk about some common equations that you need to understand to set up your game for ideal display settings.

#define floor(x) round(x)
#define ceil(x) (-round(-x))


If you don't use the above preprocessor definitions in your projects, you should start. Floor and Ceil are antonyms of one another. They deal with rounding numbers to an integer value.

Picture a number line:



BYOND's round() function is essentially a floor() function, except it allows you to provide a second argument to round to a particular decimal value. Now, a number line has two directions. Left, and right. Numbers to the left are always smaller than numbers to the right, and numbers to the right are always larger than numbers to the left. There are an infinite number of values on the number line. In between any two numbers, there are an infinite number of values that are greater than the smaller number in the sequence, and smaller than the larger number in the sequence.

Floor() will take any decimal value provided to it, and return the nearest integer to the left of the value provided, assuming it is not already an integer.

floor(0.1) = 0
floor(5.5) = 5
floor(6.1) = 6
floor(-6.1) = -7
floor(-0.5) = -1


This may seem strange to you that -6.5 rounds to -7, but that's because -6 is right of -6.5, and thus greater. The floor function always rounds to the left.

The ceil function is the opposite. It rounds to the right, and thus always rounds up.

ceil(0.1) = 1
ceil(5.5) = 6
ceil(6.1) = 7
ceil(-6.1) = -6
ceil(-0.5) = 0


Floor() and Ceil() are extremely useful functions. Get familiar with them, because if you continue your programming career beyond BYOND (lol), you are going to keep seeing them.

Important calculations:

Figuring out view dimensions from the map element's resolution:

view_width = ceil(map_width/tile_width)
view_height = ceil(map_height/tile_height)


This will result in the view potentially being larger than the map element's dimensions. This is a good thing. Don't worry. If you have HUD elements, you are going to need to account for this extra space, or your HUD elements will start disappearing off the edge of the map. I call this extra space the buffer.

Calculating the buffer sizes:

buffer_width = floor((view_width*tile_width - map_width)/2)
buffer_height = floor((view_height*tile_height - map_height)/2)


This is the minimum amount of information you need to calculate an ideal resolution.

Let's look at fine-tuning this to get around some problems in the next section.

View is too large

BYOND's viewport has some limitations. You want to keep within a certain range of tiles in the viewport at once. You ideally want to keep it below 5000 tiles in view, but keeping it even lower than that is advisable. You can tweak the maximum number of tiles in view to your specific project all you want. I recommend between 600 and 1000 tiles in the viewport any given time, but the specific needs of your game will dominate what you set those values to.

#define MAX_VIEW_TILES 800


map_zoom = 1
view_width = ceil(map_width/tile_width)
view_height = ceil(map_height/tile_height)

while(view_width*view_height>MAX_VIEW_TILES)
view_width = ceil(map_width/tile_width/++map_zoom)
view_height = ceil(map_height/tile_height/map_zoom)

buffer_width = floor((view_width*tile_width - map_width/map_zoom)/2)
buffer_height = floor((view_height*tile_height - map_height/map_zoom)/2)


The above example shows you how to calculate all the variables you need to use to work with a viewport accounting for the maximum number of tiles in the viewport. This example will increase the zoom level of the map if we exceed the maximum number of tiles and then recalculate the ideal view sizes again.

There's also another variant of this approach that I use myself. I prefer to keep the player centered in the viewport. This means that you are going to want to have a view width and height that are always odd.

map_zoom = 1
view_width = ceil(map_width/tile_width)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/tile_height)
if(!(view_height%2)) ++view_height

while(view_width*view_height>MAX_VIEW_TILES)
view_width = ceil(map_width/tile_width/++map_zoom)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/tile_height/map_zoom)
if(!(view_height%2)) ++view_height

buffer_width = floor((view_width*tile_width - map_width/map_zoom)/2)
buffer_height = floor((view_height*tile_height - map_height/map_zoom)/2)



Making it all work:

To get this all working, there are two ways you could set this up: You could perform all of the view resizing and whatnot on the client-side using Javascript, or you could perform it on the server-side. Let's show you the server-side variant first:

#define TILE_WIDTH 32
#define TILE_HEIGHT 32
#define MAX_VIEW_TILES 800

world
icon_size = 32

client
var
view_width
view_height
buffer_x
buffer_y
map_zoom
verb
onResize()
set hidden = 1
set waitfor = 0
var/sz = winget(src,"map1","size")
var/map_width = text2num(sz)
var/map_height = text2num(copytext(sz,findtext(sz,"x")+1,0))
map_zoom = 1
view_width = ceil(map_width/TILE_WIDTH)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/TILE_HEIGHT)
if(!(view_height%2)) ++view_height

while(view_width*view_height>MAX_VIEW_TILES)
view_width = ceil(map_width/TILE_WIDTH/++map_zoom)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/TILE_HEIGHT/map_zoom)
if(!(view_height%2)) ++view_height

buffer_x = floor((view_width*tile_width - map_width/map_zoom)/2)
buffer_y = floor((view_height*tile_height - map_height/map_zoom)/2)

src.view = "[view_width]x[view_height]"
winset(src,"map1","zoom=[map_zoom];")

mob
Login()
client.onResize()
return ..()


Now let's just make one small change to the interface:



You will also want to set the map up in such a way that it's anchored, and will grow with your interface appropriately if it's resized by the user. You can do this through the "Anchors" tab in the interface editor.


The client-side approach:

This approach will look fairly similar. The only difference, is we're going to move a bunch of the business end of handling resizing to the client-side. That means that you server won't have to do the work of calculating stuff. This also means that while the user is resizing the window, it will update the size of the screen on the fly rather than just when he's done like normal BYOND onResize events.

#define TILE_WIDTH 32
#define TILE_HEIGHT 32
#define MAX_VIEW_TILES 800

world
icon_size = 32
fps = 40

client
var
view_width
view_height
buffer_x
buffer_y
map_zoom

browser_loaded = 0
verb
onLoad()
set hidden = 1
browser_loaded = 1
src << output(null,"browser1:CenterWindow")

onResize(VW as num,VH as num,BX as num,BY as num,Z as num)
set hidden = 1
if(VW*VH>MAX_VIEW_TILES) return
view_width = VW
view_height = VH
buffer_x = BX
buffer_y = BY
map_zoom = Z
view = "[VW]x[VH]"
New()
spawn()
while(!browser_loaded)
src << browse('mapbrowser.html',"window=browser1")
sleep(50)
..()


mapbrowser.html:
<HTML>
<BODY>
</BODY>
<SCRIPT type="text/javascript">
var map_width;
var map_height;
var TILE_WIDTH = 32;
var TILE_HEIGHT = 32;
var MAX_VIEW_TILES = 800;

function CallVerb() {
var locstr = "byond://winset?command=" + arguments[0];
for(var count=1;count<arguments.length;count++) {
locstr += " " + encodeURIComponent(arguments[count]);
}
window.location = locstr;
}

function WinSet() {
var locstr = "byond://winset?id=" + arguments[0];
for(var count=1;count<arguments.length;count+=2) {
locstr += "&" + arguments[count] + "=" + arguments[count+1];
}
window.location = locstr;
}

function Output() {
window.location = "byond://winset?command=.output " + arguments[0] + " " + encodeURIComponent(arguments[1]);
}

window.onresize = function() {
var body = document.getElementsByTagName('body')[0];
map_width = body.clientWidth;
map_height = body.clientHeight;

var map_zoom = 1;

var view_width = Math.ceil(map_width/TILE_WIDTH);
if(!(view_width%2)) ++view_width;
var view_height = Math.ceil(map_height/TILE_HEIGHT);
if(!(view_height%2)) ++view_height;

while(view_width*view_height>MAX_VIEW_TILES) {
view_width = Math.ceil(map_width/TILE_WIDTH/++map_zoom);
if(!(view_width%2)) ++view_width;
view_height = Math.ceil(map_height/TILE_HEIGHT/map_zoom);
if(!(view_height%2)) ++view_height;
}

var buffer_x = Math.floor((view_width*TILE_WIDTH - map_width/map_zoom)/2);
var buffer_y = Math.floor((view_height*TILE_HEIGHT - map_height/map_zoom)/2);

WinSet("map1","zoom",map_zoom);
CallVerb("onResize",view_width,view_height,buffer_x,buffer_y,map_zoom);
};

window.onload = function() {
CallVerb("onLoad");
};

var isfullscreen = 0;

function ToggleFullscreen() {
if(isfullscreen) {
WinSet("default","titlebar","true","is-maximized","false","can-resize","true");
isfullscreen = 0;
} else {
WinSet("default","titlebar","false")
WinSet("default","is-maximized","true","can-resize","false");
isfullscreen = 1;
}
}

var resolution_x;
var resolution_y;

function CenterWindow() {
window.location = "byond://winget?callback=CenterWindowCallback&id=default&property=size";
}

function CenterWindowCallback(properties) {
var win_width = properties.size.x;
var win_height = properties.size.y;
resolution_x = screen.width;
resolution_y = screen.height;
WinSet("default","pos",Math.floor((resolution_x-win_width)/2) + "," + Math.floor((resolution_y-win_height)/2));
}
</SCRIPT>
</HTML>


Now, setting up your interface to work with this requires just a few modifications. First, let's add a macro:



Now, you also need to set up your main view in such a way that there is a browser element named "browser1" hidden underneath your map element. They should have the same x/y location and width/height. They should also have the exact same anchor values. This is important. Don't mess it up.



Make sure to set the map to your icon size!

That's actually all you need to do for setting up the interface to work with this approach.

Next, we're going to talk about setting up your HUD objects a bit:

Dealing with objects on the screen

Let's face it, BYOND's screen_loc variable kind of sucks. You can, however make it suck a little less if you use it to your advantage.

Let's take a look at some neat stuff nobody ever told you you could do:

screen_loc = "1:400,1:200"


You can use >tile_size pixel offsets in either direction to get pixel perfect positioning of elements without even worrying about doing all that tile/pixel math crap we always waste our time doing. Don't use the tile:pixel format. It's not worth the time/effort. Use 1:pixel format.

Did you know you could use anchors to set an object's screen location too? Check this out:

screen_loc = "WEST+0:320,NORTH+0:-400"


In this example, the screen object is 320 pixels to the left of the west-most tile in the view, and 400 pixels south from the northernmost tile in the view. Cool, right?

Here are the strings you can use as anchors:

WEST, SOUTH, NORTH, EAST, CENTER

When you use a dynamic screen size, you need to remember that objects on the screen need to be anchored to one of the above values in order to make sure that changing the screen size doesn't impact the player's ability to see interface elements. You also have to remember that my approach allows the edge of the viewport to be outside of the map's viewable area, so you also need to account for this. Here's a really quick snippet that will let you account for it all:

#define HUD_LAYER 10

hudobj
parent_type = /obj
layer = HUD_LAYER
var
client/client
anchor_x = "WEST"
anchor_y = "SOUTH"
screen_x = 0
screen_y = 0
width = TILE_WIDTH
height = TILE_HEIGHT
proc
setSize(W,H)
width = W
height = H
if(anchor_x!="WEST"||anchor_y!="SOUTH")
updatePos()

setPos(X,Y,AnchorX="WEST",AnchorY="SOUTH")
screen_x = X
anchor_x = AnchorX
screen_y = Y
anchor_y = AnchorY
updatePos()

updatePos()
var/ax
var/ay
var/ox
var/oy
switch(anchor_x)
if("WEST")
ax = "WEST+0"
ox = screen_x + client.buffer_x
if("EAST")
if(width>TILE_WIDTH)
var/tx = ceil(width/TILE_WIDTH)
ax = "EAST-[tx-1]"
ox = tx*TILE_WIDTH - width - client.buffer_x + screen_x
else
ax = "EAST+0"
ox = TILE_WIDTH - width - client.buffer_x + screen_x
if("CENTER")
ax = "CENTER+0"
ox = floor((TILE_WIDTH - width)/2) + screen_x
switch(anchor_y)
if("SOUTH")
ay = "SOUTH+0"
oy = screen_y + client.buffer_y
if("NORTH")
if(height>TILE_HEIGHT)
var/ty = ceil(height/TILE_HEIGHT)
ay = "NORTH-[ty-1]"
oy = ty*TILE_HEIGHT - height - client.buffer_y + screen_y
else
ay = "NORTH+0"
oy = TILE_HEIGHT - height - client.buffer_y + screen_y
if("CENTER")
ay = "CENTER+0"
oy = floor((TILE_HEIGHT - height)/2) + screen_y
screen_loc = "[ax]:[ox],[ay]:[oy]"

show()
updatePos()
client.screen += src

hide()
client.screen -= src

New(loc=null,client/Client,list/Params,show=1)
client = Client
for(var/v in Params)
vars[v] = Params[v]
if(show) show()


Now, all you have to do is inform the screen objects when the client's view size has changed:

client
verb
onResize(VW as num,VH as num,BX as num,BY as num,Z as num)
set hidden = 1
if(VW*VH>MAX_VIEW_TILES) return
view_width = VW
view_height = VH
buffer_x = BX
buffer_y = BY
map_zoom = Z
view = "[VW]x[VH]"
for(var/hudobj/h in screen)
h.updatePos()


Using these little guys is really simple:

client
var
hudobj/topleft
hudobj/topright
hudobj/bottomleft
hudobj/bottomright
hudobj/center
New()
spawn()
while(!browser_loaded)
src << browse('mapbrowser.html',"window=browser1")
sleep(50)
topleft = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="WEST",anchor_y="NORTH"),1)
topright = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="EAST",anchor_y="NORTH"),1)
bottomleft = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="WEST",anchor_y="SOUTH"),1)
bottomright = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="EAST",anchor_y="SOUTH"),1)
center = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="CENTER",anchor_y="CENTER"),1)
..()
Del()
//get in the habit of cleaning up circular references
topleft = null
topright = null
bottomleft = null
bottomright = null
center = null
screen = list()
..()


And that's really all there is to it! Making your interface dynamic and responsive is absolutely imperative to creating a polished product that people outside of BYOND will immediately recognize as a real game. The more presentable and user-friendly your design is, the better received your final product will be. Every bit of clunk in your interface is going to turn people away the moment they start playing your game, so learning to do simple stuff like this to make your game more accessible and responsive is only going to help you in the long run.

As always, questions and comments below! Cheers!
Awesome work!
This will be really helpful, can't wait to take a look when I get the time
Great post, worked perfectly for me!
fixed some minor errors in the hudobj section.
#UnityMasterRace
Minor correction made: I had accidentally written floor a half dozen times when I meant ceil.
I'm curious. When would you not want it handled via the client side? I'm trying to think of instances and none pop up off the top of my head.

Thanks for these Snippet Sundays, by the way.
Well, all the client-sided business is largely considered a hack by the BYOND community. Because the client-side depends on compatability-mode IE, there's a widespread belief that it's unreliable.

I'm about the only person in this community that uses the client-side DM<->Javascript communication built into DreamSeeker. I'm actually the person that requested the client-side winset/winget commands be implemented in the first place.

It's a relatively new feature, and as such, the majority of our community won't use anything that was added to the language after about 2005... So... To answer your question directly? Probably never.

It's important to note that you really ought to have good knowledge of Javascript and browser vendor compatability issues before you go traipsing around trying to make your interface client-side in DS. In all honesty, I'd recommend the web client if it didn't have serious drawbacks at the moment that are still keeping me from migrating to it. Although, the lifetime of DS is waning and the web client is looking more and more attractive with every sequential BYOND release.

Also, it's good to remember that the client can potentially access the information you are sending them by looking through their cache. It's probably best to consider anything that can be done on the client side unreliable, and thus vet any information that's coming in through the Javascript end of the pipeline, because if you aren't careful, malicious players can and will take advantage of your interface. You'll notice that I made sure that the view values didn't get out of range even though they should never get out of range on the client-side. I'm just being doubly-safe to make sure that nobody calls the verb for changing the screen area with nonsense values to gain an unfair advantage over other players, or to do something that effectively could harm the server. I don't have to do much sanity testing, as any other changes the player makes illegally won't have negative effects for anyone but themselves. All they can do is make their interface unusable for themselves. If they were able to set their view to an insanely huge value, that could impact other players by soaking up bandwidth or giving them an unfair advantage. That's why I made sure the view size didn't get out of range on the server side after calculating it on the client side.
Just my two cents, but, with
<meta http-equiv="X-UA-Compatible" content="IE=edge">


in the <head> tags, the client-sided part of this can break as IE11 doesn't use
body.clientWidth and
body.clientHeight;

for tracking the width/height of the browser. Instead, to make it work regardless of the presence of the meta tag in the header, you can use:
var w = window.innerWidth
|| document.documentElement.clientWidth
|| document.body.clientWidth;

var h = window.innerHeight
|| document.documentElement.clientHeight
|| document.body.clientHeight;
map_width = w;
map_height = h;


instead.
Just a quick update, I fixed some minor issues with hudobj positioning.
DISCLAIMER: If you have questions about this or any of my tutorials, do not PM me. Post questions about this article in this thread.

If you did not understand this tutorial, ask a question about the part that you did not understand.

Do not, under any circumstances ask me to make this into a video tutorial or a demo. If you can't tell me what part you didn't understand in specific, I can't help you, so don't waste my time via PM telling me you didn't understand any of it. I'm not going to mollycoddle people who refuse to read 1790 words anymore.
All i do is wait till 3am in the morning when im bored out of my mind, and i read it then.
I can apply this to the position of the windows interface?
I can apply this to the position of the windows interface?

Gonna need clarification.
Well, I mean that when having several windows of my interface and maximize or resize the window princial, secondary windows are automatically adjusted as do the objects in this case.

It can be applied?
Well, I mean that when having several windows of my interface and maximize or resize the window princial, secondary windows are automatically adjusted

Probably not if I understood that correctly.

You can't really predict where a window should be in relation to another because windows are all top-level elements. Top-level elements do not position or size with respect to one another.
thank you ter13
Ter what do we do if the buffers aren't accurate, like for some resolutions/pcs hud objects displays and for others it doesn't.
buffer_x = floor((view_width*TILE_WIDTH - map_width/map_zoom)/2)
buffer_y = floor((view_height*TILE_HEIGHT - map_height/map_zoom)/2)

In the original code it was lowercase tile_width and tile_height I'm guessing it was like that for a reason?
Read the last couple paragraphs. Your entire HUD has to be designed with those buffers in mind.
Page: 1 2