In the last post we looked at how to organize the data for our project, something which upon starting the code side of the project, I already realize will need to be revised at some point. Fortunately, it won’t affect anything we’re doing here.
As with prior posts, you should consider this one a rough draft. I will be revisiting each and revising as I go forward.
The goals of this post are to:
As mentioned before, we’re going to be using SvelteKit to create the client side, though the techniques should transfer well enough to other frameworks.
If you’d like build the project pieces by piece, following what’s here and in the repo, then you’ll need to install a few things. Of course, you can also clone the repo and checkout this branch in order to see the finished portion as per this post.
You will need to install a recent version of node and npm here and then follow the instructions on the SvelteKit page to get a project started. Make sure to select Typescript during the installation if you want to follow along verbatim.
Once you have that done, make sure your *tsconfig.json matches mine, or you may get errors.
In order to create our infinite world we’re going to treat the entire body of our webpage as a single container. In the src/ folder at the root of your project you’ll find an app.html file. You should modify the style of the body tag in order to remove padding, the ability to scroll, and to ensure that it fills the screen.
<body
data-sveltekit-preload-data="hover"
style="
padding: 0px;
height: 100vh;
width: 100vw;
margin: 0px;
overflow: hidden
">
<div style="display: contents">%sveltekit.body%</div>
...
</body>
We’ll end up coming back here at some point, but for now that’s all that’s needed to set up the space.
We’re going to have some variables which we’ll share throughout the client-side portion of our app, and while we could define them in the root component and use prop drilling, it’s a lot easier to add a few stores to handle this.
If you’re unfamiliar with stores, they’re essentially a name for global variables, something you may have had beaten into you at some point as being bad. In a way, they can be, but thankfully, with Svelte they won’t pollute our namespace as they’re prefaced with a $, as we’ll see.
We’re going to create two stores to start, and we’ll make the folder src/lib/stores to put them in. In that folder, create constants.store.ts and add the following:
import { readable } from 'svelte/store';
export const CELL_WIDTH = readable(64);
export const CELL_HEIGHT = readable(64);
export const REGION_WIDTH = readable(3200);
export const REGION_HEIGHT = readable(2048);
export const UPDATE_DISTANCE = readable(200);
These are the same values we discussed in our first post. By keeping these values here, we can easily adjust later on if we determine our region or cell sizes need to be adjusted. You’ll notice that we’ve defined them as readable, which means they can’t have their value changed from outside the store. There are ways to change these, but we won’t be doing that.
The second store we’ll call world.store.ts, and it will hold the mutable state of the world, such as our location and the regions we’re currently viewing. We’ll be adding more as we implement features later on.
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export const x = writable(0);
export const y = writable(0);
export const windowWidth = writable(0);
export const windowHeight = writable(0);
export const regionsInView: Writable<[number, number][]> = writable([]);
At this point, the regionsInView list is only holding the coordinates of the regions currently needing to be loaded, not the region data itself. That is something which will be discussed more once we have some data to work with.
We’re not going to use an HTML5 canvas, but instead a div you can move around by clicking and dragging. In truth, we’re not going to be moving anything, but instead giving the appearance of doing so. To do that we create a background grid to show the illusion of movement, while updating our stored values x and y.
To do this, create the files (and paths) src/routes/+page.svelte, src/lib/components/Navbar.svelte and src/lib/components/World.svelte. The ** World.svelte** file will be where most of the magic happens, but let’s first look at +page.svelte where you’ll want to drop in the following code:
<script>
// Stores
import { x, y, windowWidth, windowHeight } from '$lib/stores/world.store';
// Components
import Navbar from '$lib/components/Navbar.svelte';
import World from '$lib/components/World.svelte';
</script>
<svelte:window bind:innerWidth={$windowWidth} bind:innerHeight={$windowHeight} />
<Navbar worldX={$x} worldY={$y} />
<World />
The first important thing we’re doing here is capturing the width and height of our window with svelte:window and updating them when the window is resized. The bind is assigning the browser’s innerWidth variable (the viewable space) to our stored value. If the user resizes their window, this value is automatically updated, along with wherever it was used. Note that dollar sign symbol I mentioned earlier, which denotes a store value being used.
The second important thing is we’re passing the variables to our Navbar component. Whenever the store value of either is updated it’ll update those values in the component.
Note, that right now your code will likely be throwing all sorts of errors, but we’ll rectify those shortly.
Next, let’s look at the Navbar component. I’m not going to paste the full code here, but you can find it in the repo. Instead, I’ll go over a few important things.
The first thing is at the start of the component, with these two lines:
export let worldX: number | string; // Since bound to an input field
export let worldY: number | string; // Since bound to an input field
These are the $x and $y values we passed in from +page.svelte, and we’re declaring them as being both a number and a string, which seems odd. The reason for this is we use these values, worldX and worldY, within input fields, which naturally work with strings. These input fields can be used by the player to teleport around the world. Users will enter their desired coordinate and hit ENTER or the button.
But, since we need integers whenever we update our location in the world, we use the checkCoordinate function to ensure that they are numbers prior to teleporting. We also have some error checking on the input fields which change the color of the input box if the user enters an invalid value, as shown below:
<div class="location no-drag">
<input class={xIsInt ? '' : 'has-error'} bind:value={worldX} on:keyup={handleKeyup} />
<input class={yIsInt ? '' : 'has-error'} bind:value={worldY} on:keyup={handleKeyup} />
<button type="submit" on:click={teleport}>Teleport</button>
</div>
The second thing is in the CSS, which is somewhat extensive for this rather simple component. You can see this particularly CSS class used in the outer div of the above snippet, no-drag.
.no-drag {
user-select: unset;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-drag: none;
user-drag: none;
-ms-user-drag: none;
user-drag: none;
}
One of the issues we’ll face when we’re capturing all these mouse events, is the unexpected behavior that comes when clicking and draggin on elements like our navbar. To avoid that, we’ll override a number of things, like here we disallow selection and dragging. This will help us avoid accidentally dragging parts of our navbar.
Now let’s start drawing our world in the World.svelte component. As with the Navbar, I’m going to pull out only the important code bits, but you can find the complete file here.
To start off, let’s look at the important bits, stripping out everything else:
<script lang="ts">
function distance(x1: number, y1: number, x2: number, y2: number): number { }
function updateRegionsInView() { }
function handleStartDrag(event: MouseEvent) { }
function handleStopDrag(event: MouseEvent) { }
function handleMove(event: MouseEvent) { }
$: backgroundPosition = `${-$x % $REGION_WIDTH}px ${-$y % $REGION_HEIGHT}px`;
</script>
<svelte:window
on:mousedown={handleStartDrag}
on:mouseup={handleStopDrag}
on:mouseout={handleStopDrag}
on:mousemove={handleMove}
on:click|preventDefault
on:dblclick|preventDefault
/>
You’ll notice we’re using svelte:window again, this time capturing all the mouse events and sending them to our own functions. The other interesting bit here is the backgroundPosition line, which is after $:, denoting a reactive statement. This means that every time $x or $y change, the backgroundPosition variable is updated with a string of the current world position. We use this within the single line of HTML this file has:
<div class="grid" style="background-position: {backgroundPosition};" />
This works in tandem with the grid class to produce a nice background grid drawn using this CSS:
.grid {
width: 100vw;
height: 100vh;
background-size: 64px 64px;
background-image:
linear-gradient(to left, rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(to top, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
}
The result is as such:
This stretches the div to fill the background and then repeatedly draws a one pixel line every 64 pixels, both horizontally and vertically. By using the backgroundPosition we can offset the grid as we move around the world. I’m using modulus as I’m not sure exactly how much will be drawn if I start it at something like( -15000, 15000). I believe it will draw these offscreen, as well.
Most of the other code in this file is fairly straightforward, but there is one more small thing that I want to mention, and that’s in the handleMove function. We only move the grid when we’re holding the left mouse button and dragging, and as we do we’re tracking the previous location the mouse was at using the previousX and previousY variables.
The code looks like this:
if (dragging) {
$x = $x - event.clientX + previousX;
$y = $y - event.clientY + previousY;
previousX = event.clientX;
previousY = event.clientY;
<REST-OF-CODE>
}
What we’re doing here is taking the difference between where the cursor currently is and where it last was. While we could assign $x to be equal to event.clientX, for example, and it would have the same dragging effect, it alter our $x based on where we clicked on the screen. You can test this out to see how it works.
There isn’t much else here, aside from getting the current regions in view, which is not much more than a bit of match to check which regions the current screen’s view overlaps with. This is something we’ll discuss in more detail once we’ve wired up the database, so I’m going to leave off explaining it in detail until then.
At this point you should be able to run the code using npm run dev at the terminal, and you’ll see a nice grid you’re able to move around.
This code will act as the basis for everything we do going forward, and the game itself, we’re going to updating this post, and the code therein, multiple times as we go forward.