View Source Web Frontend with LiveView
Since we're using Phoenix, we can take advantage of the many features it brings for building a web interface.
player-auth
Player Auth
When it comes to player auth, there are two sides to the coin: authentication (AuthN) and authorization (AuthZ). The former refers to verifying the identity of a player (and will be our primary focus, for now), while the latter refers to checking whether a user has permission to take a restricted action.
Phoenix comes with an AuthN generator built-in, which should be more than enough for our needs:
$ mix phx.gen.auth Players Player players --binary-id
$ mix deps.get
$ mix ecto.migrate
This will expect players to register an email and password, which will be used to log in. A unique ID will also be created for each player upon registration, allowing us to begin thinking of players as entities. However, we can't just take the player input and start creating components with it - only systems can create components. Instead, we'll use a special component type provided for this purpose: ECSx.ClientEvents
.
client-input-via-liveview
Client Input via LiveView
First consider the goals for our frontend:
- Authenticate the player and hold player ID
- Spawn the player's ship upon connection (writes components)
- Hold the coordinates for the player's ship
- Hold the coordinates for enemy ships
- Validate user input to move the ship (writes components)
When we need to write components, ECSx.ClientEvents
will be our line of communication from the frontend to the backend.
Let's create /lib/ship_web/live/game_live.ex
and put it to use:
defmodule ShipWeb.GameLive do
use ShipWeb, :live_view
alias Ship.Components.HullPoints
alias Ship.Components.XPosition
alias Ship.Components.YPosition
def mount(_params, %{"player_token" => token} = _session, socket) do
# This context function was generated by phx.gen.auth
player = Ship.Players.get_player_by_session_token(token)
socket =
socket
|> assign(player_entity: player.id)
# Keeping a set of currently held keys will allow us to prevent duplicate keydown events
|> assign(keys: MapSet.new())
# We don't know where the ship will spawn, yet
|> assign(x_coord: nil, y_coord: nil, current_hp: nil)
# We don't want these calls to be made on both the initial static page render and again after
# the LiveView is connected, so we wrap them in `connected?/1` to prevent duplication
if connected?(socket) do
ECSx.ClientEvents.add(player.id, :spawn_ship)
:timer.send_interval(50, :load_player_info)
end
{:ok, socket}
end
def handle_info(:load_player_info, socket) do
# This will run every 50ms to keep the client assigns updated
x = XPosition.get(socket.assigns.player_entity)
y = YPosition.get(socket.assigns.player_entity)
hp = HullPoints.get(socket.assigns.player_entity)
{:noreply, assign(socket, x_coord: x, y_coord: y, current_hp: hp)}
end
def handle_event("keydown", %{"key" => key}, socket) do
if MapSet.member?(socket.assigns.keys, key) do
# Already holding this key - do nothing
{:noreply, socket}
else
# We only want to add a client event if the key is defined by the `keydown/1` helper below
maybe_add_client_event(socket.assigns.player_entity, key, &keydown/1)
{:noreply, assign(socket, keys: MapSet.put(socket.assigns.keys, key))}
end
end
def handle_event("keyup", %{"key" => key}, socket) do
# We don't have to worry about duplicate keyup events
# But once again, we will only add client events for keys that actually do something
maybe_add_client_event(socket.assigns.player_entity, key, &keyup/1)
{:noreply, assign(socket, keys: MapSet.delete(socket.assigns.keys, key))}
end
defp maybe_add_client_event(player_entity, key, fun) do
case fun.(key) do
:noop -> :ok
event -> ECSx.ClientEvents.add(player_entity, event)
end
end
defp keydown(key) when key in ~w(w W ArrowUp), do: {:move, :north}
defp keydown(key) when key in ~w(a A ArrowLeft), do: {:move, :west}
defp keydown(key) when key in ~w(s S ArrowDown), do: {:move, :south}
defp keydown(key) when key in ~w(d D ArrowRight), do: {:move, :east}
defp keydown(_key), do: :noop
defp keyup(key) when key in ~w(w W ArrowUp), do: {:stop_move, :north}
defp keyup(key) when key in ~w(a A ArrowLeft), do: {:stop_move, :west}
defp keyup(key) when key in ~w(s S ArrowDown), do: {:stop_move, :south}
defp keyup(key) when key in ~w(d D ArrowRight), do: {:stop_move, :east}
defp keyup(_key), do: :noop
def render(assigns) do
~H"""
<div id="game" phx-window-keydown="keydown" phx-window-keyup="keyup">
<p>Player ID: <%= @player_entity %></p>
<p>Player Coords: <%= inspect({@x_coord, @y_coord}) %></p>
<p>Hull Points: <%= @current_hp %></p>
</div>
"""
end
end
handling-client-events
Handling Client Events
Finally, spin up a new system for handling the events:
$ mix ecsx.gen.system ClientEventHandler
defmodule Ship.Systems.ClientEventHandler do
...
@behaviour ECSx.System
alias Ship.Components.ArmorRating
alias Ship.Components.AttackDamage
alias Ship.Components.AttackRange
alias Ship.Components.AttackSpeed
alias Ship.Components.HullPoints
alias Ship.Components.SeekingTarget
alias Ship.Components.XPosition
alias Ship.Components.XVelocity
alias Ship.Components.YPosition
alias Ship.Components.YVelocity
@impl ECSx.System
def run do
client_events = ECSx.ClientEvents.get_and_clear()
Enum.each(client_events, &process_one/1)
end
defp process_one({player, :spawn_ship}) do
# We'll give player ships better stats than the enemy ships
# (otherwise the game would be very short!)
ArmorRating.add(player, 2)
AttackDamage.add(player, 6)
AttackRange.add(player, 15)
AttackSpeed.add(player, 1.2)
HullPoints.add(player, 75)
SeekingTarget.add(player)
XPosition.add(player, Enum.random(1..100))
YPosition.add(player, Enum.random(1..100))
XVelocity.add(player, 0)
YVelocity.add(player, 0)
end
# Note Y movement will use screen position (increasing Y goes south)
defp process_one({player, {:move, :north}}), do: YVelocity.update(player, -1)
defp process_one({player, {:move, :south}}), do: YVelocity.update(player, 1)
defp process_one({player, {:move, :east}}), do: XVelocity.update(player, 1)
defp process_one({player, {:move, :west}}), do: XVelocity.update(player, -1)
defp process_one({player, {:stop_move, :north}}), do: YVelocity.update(player, 0)
defp process_one({player, {:stop_move, :south}}), do: YVelocity.update(player, 0)
defp process_one({player, {:stop_move, :east}}), do: XVelocity.update(player, 0)
defp process_one({player, {:stop_move, :west}}), do: XVelocity.update(player, 0)
end
Notice how the LiveView client can write to ECSx.ClientEvents
, while the system handles and also clears the events. This ensures that we don't process the same event twice, nor will any events get "lost" and not processed.
creating-a-phoenix-route
Creating a Phoenix Route
Head into router.ex
and look for the new scope which uses :require_authenticated_player
. We're going to add a new route for our game interface:
scope "/", ShipWeb do
pipe_through [:browser, :require_authenticated_player]
live_session :require_authenticated_player,
on_mount: [{ShipWeb.PlayerAuth, :ensure_authenticated}] do
live "/game", GameLive
...
end
end
Now we can run
$ iex -S mix phx.server
and go to localhost:4000/game
to test the input. Once you are logged in, wait for the player coords to display (this will be the indicator that your ship has spawned), and try moving around with WASD
or arrow keys!
loading-screen
Loading Screen
You might notice that while the ship is spawning, the Player Coords and Hull Points don't display properly - this isn't a major issue now, but once our coordinates are being used by a more sophisticated display, this will not be acceptable. What we need is a loading screen to show the user until the necessary data is properly loaded.
First, let's create a new ECSx.Tag
to mark when a player's ship has finished spawning:
$ mix ecsx.gen.tag PlayerSpawned
Then we'll add this tag at the end of the :spawn_ship
client event
defmodule Ship.Systems.ClientEventHandler do
...
alias Ship.Components.PlayerSpawned
...
defp process_one({player, :spawn_ship}) do
...
PlayerSpawned.add(player)
end
...
end
Now we'll update our LiveView to use a new @loading
assign which is initially set to true
, then set to false
after the ship is spawned and the data is loaded for the first time.
Replace both the current mount
and handle_info
functions with the below functions, and replace the existing render
function
with the new render
function below
defmodule ShipWeb.GameLive do
...
alias Ship.Components.PlayerSpawned
...
def mount(_params, %{"player_token" => token} = _session, socket) do
player = Ship.Players.get_player_by_session_token(token)
socket =
socket
|> assign(player_entity: player.id)
|> assign(keys: MapSet.new())
# This gets its own helper in case we need to return to this state again later
|> assign_loading_state()
if connected?(socket) do
ECSx.ClientEvents.add(player.id, :spawn_ship)
# The first load will now have additional responsibilities
send(self(), :first_load)
end
{:ok, socket}
end
defp assign_loading_state(socket) do
assign(socket,
x_coord: nil,
y_coord: nil,
current_hp: nil,
# This new assign will control whether the loading screen is shown
loading: true
)
end
def handle_info(:first_load, socket) do
# Don't start fetching components until after spawn is complete!
:ok = wait_for_spawn(socket.assigns.player_entity)
socket =
socket
|> assign_player_ship()
|> assign(loading: false)
# We want to keep up-to-date on this info
:timer.send_interval(50, :refresh)
{:noreply, socket}
end
def handle_info(:refresh, socket) do
{:noreply, assign_player_ship(socket)}
end
defp wait_for_spawn(player_entity) do
if PlayerSpawned.exists?(player_entity) do
:ok
else
Process.sleep(10)
wait_for_spawn(player_entity)
end
end
# Our previous :load_player_info handler becomes a shared helper for the new handlers
defp assign_player_ship(socket) do
x = XPosition.get(socket.assigns.player_entity)
y = YPosition.get(socket.assigns.player_entity)
hp = HullPoints.get(socket.assigns.player_entity)
assign(socket, x_coord: x, y_coord: y, current_hp: hp)
end
def handle_event("keydown", %{"key" => key}, socket) do
...
def render(assigns) do
~H"""
<div id="game" phx-window-keydown="keydown" phx-window-keyup="keyup">
<%= if @loading do %>
<p>Loading...</p>
<% else %>
<p>Player ID: <%= @player_entity %></p>
<p>Player Coords: <%= inspect({@x_coord, @y_coord}) %></p>
<p>Hull Points: <%= @current_hp %></p>
<% end %>
</div>
"""
end
end
player-gui-using-svg
Player GUI using SVG
One of the simplest ways to build a display for web is with SVG. Each entity can be represented by a single SVG element, which only requires its coordinates. Then a viewBox
can zoom the player's display in to show just the local area around their ship.
defmodule ShipWeb.GameLive do
...
def render(assigns) do
~H"""
<div id="game" phx-window-keydown="keydown" phx-window-keyup="keyup">
<svg
viewBox={"#{@x_offset} #{@y_offset} #{@screen_width} #{@screen_height}"}
preserveAspectRatio="xMinYMin slice"
>
<rect width={@game_world_size} height={@game_world_size} fill="#72eff8" />
<%= if @loading do %>
<text x={div(@screen_width, 2)} y={div(@screen_height, 2)} style="font: 1px serif">
Loading...
</text>
<% else %>
<image
x={@x_coord}
y={@y_coord}
width="1"
height="1"
href={~p"/images/#{@player_ship_image_file}"}
/>
<%= for {_entity, x, y, image_file} <- @other_ships do %>
<image
x={x}
y={y}
width="1"
height="1"
href={~p"/images/#{image_file}"}
/>
<% end %>
<text x={@x_offset} y={@y_offset + 1} style="font: 1px serif">
Hull Points: <%= @current_hp %>
</text>
<% end %>
</svg>
</div>
"""
end
end
We've added a lot here, so let's go line-by-line:
We're filling the screen with an svg viewBox
, which takes four arguments. The first two - x and y offsets - tell the viewBox what area of the map to focus on, while the latter two - screen width and height - tell it how much to zoom in. To get the offsets, we'll need to calculate the coordinate pair which should be at the very top-left of the player's view. This will need to be updated every time the player moves. The screen width and height (measured by game coordinates) will be assigned on mount and won't change. The preserveAspectRatio
has two parts: xMinYMin
means we define the offset by the top-left coordinate, and slice
means we're only expecting to show a "slice" of the map in our viewBox, not the whole thing.
Our first element will be a simple rect
(rectangle) with a light-blue fill to cover the entire world map. This will be the "background" - representing the ocean. The game world will consistently be 100x100, so we can assign a game_world_size
of 100 for this purpose.
The loading screen will still use the same viewBox and background, but with only one other element - text
to display a loading message in the center of the screen. One curiosity regarding the font size: we use 1px
, but you'll see later that the text is actually quite large. This is because the viewBox considers each game "tile" to be 1 pixel, and automatically scales these pixels up to a larger size based on the screen width and height compared to the size of the browser window. So when we say font: 1px
it means the text will be as tall as one game tile.
Once the game is finished loading, we'll display three things: the player's ship, other ships, and the player's current HP.
For the player's ship, we'll make an image
element, using the existing x and y coordinates, defining the size as one game tile, and pointing to the player's ship image file.
For other ships, we'll need a new assign to hold that data - ID and coordinates, at minimum. Then each one will get an image
just like the player's ship.
Lastly, we'll put an HP display near the top-left corner.
Lets first create an ImageFile
component:
$ mix ecsx.gen.component ImageFile binary
Next step is to update our LiveView with these new assigns:
defmodule ShipWeb.GameLive do
...
alias Ship.Components.ImageFile
...
def mount(_params, %{"player_token" => token} = _session, socket) do
player = Ship.Players.get_player_by_session_token(token)
socket =
socket
|> assign(player_entity: player.id)
|> assign(keys: MapSet.new())
# These will configure the scale of our display compared to the game world
|> assign(game_world_size: 100, screen_height: 30, screen_width: 50)
|> assign_loading_state()
if connected?(socket) do
ECSx.ClientEvents.add(player.id, :spawn_ship)
send(self(), :first_load)
end
{:ok, socket}
end
defp assign_loading_state(socket) do
assign(socket,
x_coord: nil,
y_coord: nil,
current_hp: nil,
player_ship_image_file: nil,
other_ships: [],
x_offset: 0,
y_offset: 0,
loading: true
)
end
def handle_info(:first_load, socket) do
:ok = wait_for_spawn(socket.assigns.player_entity)
socket =
socket
|> assign_player_ship()
|> assign_other_ships()
|> assign_offsets()
|> assign(loading: false)
:timer.send_interval(50, :refresh)
{:noreply, socket}
end
def handle_info(:refresh, socket) do
socket =
socket
|> assign_player_ship()
|> assign_other_ships()
|> assign_offsets()
{:noreply, socket}
end
defp wait_for_spawn(player_entity) do
if PlayerSpawned.exists?(player_entity) do
:ok
else
Process.sleep(10)
wait_for_spawn(player_entity)
end
end
defp assign_player_ship(socket) do
x = XPosition.get(socket.assigns.player_entity)
y = YPosition.get(socket.assigns.player_entity)
hp = HullPoints.get(socket.assigns.player_entity)
image = ImageFile.get(socket.assigns.player_entity)
assign(socket, x_coord: x, y_coord: y, current_hp: hp, player_ship_image_file: image)
end
defp assign_other_ships(socket) do
other_ships =
Enum.reject(all_ships(), fn {entity, _, _, _} -> entity == socket.assigns.player_entity end)
assign(socket, other_ships: other_ships)
end
defp all_ships do
for {ship, _hp} <- HullPoints.get_all() do
x = XPosition.get(ship)
y = YPosition.get(ship)
image = ImageFile.get(ship)
{ship, x, y, image}
end
end
defp assign_offsets(socket) do
# Note: the socket must already have updated player coordinates before assigning offsets!
%{screen_width: screen_width, screen_height: screen_height} = socket.assigns
%{x_coord: x, y_coord: y, game_world_size: game_world_size} = socket.assigns
x_offset = calculate_offset(x, screen_width, game_world_size)
y_offset = calculate_offset(y, screen_height, game_world_size)
assign(socket, x_offset: x_offset, y_offset: y_offset)
end
defp calculate_offset(coord, screen_size, game_world_size) do
case coord - div(screen_size, 2) do
offset when offset < 0 -> 0
offset when offset > game_world_size - screen_size -> game_world_size - screen_size
offset -> offset
end
end
def handle_event("keydown", %{"key" => key}, socket) do
...
end
Next let's create the ImageFile
components when a ship is spawned:
defmodule Ship.Manager do
...
def startup do
for _ships <- 1..40 do
...
Ship.Components.ImageFile.add(entity, "npc_ship.svg")
end
end
...
end
defmodule Ship.Systems.ClientEventHandler do
...
alias Ship.Components.ImageFile
...
defp process_one({player, :spawn_ship}) do
...
ImageFile.add(player, "player_ship.svg")
PlayerSpawned.add(player)
end
...
end
Lastly, we'll need the player_ship.svg and npc_ship.svg files. Right-click on the links and save them to priv/static/images/
, where they will be found by our ~p"/images/..."
calls in the LiveView template.
Now running
$ iex -S mix phx.server
and heading to localhost:4000/game
should provide a usable game interface to move your ship around, ideally keeping it out of attack range of enemy ships, while remaining close enough for your own ship to attack (remember that we gave the player ship a longer attack range than the enemy ships).
projectile-animations
Projectile Animations
Currently the most challenging part of the game is knowing when your ship is attacking, and when it is being attacked. Let's implement a new feature to make attacks visible to the player(s). There are several ways to go about this; we're going to take an approach that showcases ECS design:
- Instead of an attack immediately dealing damage, it will spawn a cannonball entity
- The cannonball entity will have position and velocity components, like ships do
- It will also have new components such as
ProjectileTarget
andProjectileDamage
- A
Projectile
system will guide it to its target, then destroy the cannonball and deal damage - In our LiveView, we'll create a new assign to hold the locations of projectiles
- The new assign will be used to create SVG elements
- To help fetch locations for projectiles only, we'll add an
IsProjectile
tag
Start by running the generator commands for our new components, systems, and tag:
$ mix ecsx.gen.component ProjectileTarget binary
$ mix ecsx.gen.component ProjectileDamage integer
$ mix ecsx.gen.system Projectile
$ mix ecsx.gen.tag IsProjectile
Then we need to update the Attacking
system to spawn projectiles instead of immediately dealing damage. We'll replace the existing deal_damage/2
with a spawn_projectile/2
:
defmodule Ship.Systems.Attacking do
...
@behaviour ECSx.System
alias Ship.Components.AttackCooldown
alias Ship.Components.AttackDamage
alias Ship.Components.AttackRange
alias Ship.Components.AttackSpeed
alias Ship.Components.AttackTarget
alias Ship.Components.ImageFile
alias Ship.Components.IsProjectile
alias Ship.Components.ProjectileDamage
alias Ship.Components.ProjectileTarget
alias Ship.Components.SeekingTarget
alias Ship.Components.XPosition
alias Ship.Components.XVelocity
alias Ship.Components.YPosition
alias Ship.Components.YVelocity
alias Ship.SystemUtils
...
defp attack_if_ready({self, target}) do
cond do
...
:otherwise ->
spawn_projectile(self, target)
add_cooldown(self)
end
end
defp spawn_projectile(self, target) do
attack_damage = AttackDamage.get(self)
x = XPosition.get(self)
y = YPosition.get(self)
# Armor reduction should wait until impact to be calculated
cannonball_entity = Ecto.UUID.generate()
IsProjectile.add(cannonball_entity)
XPosition.add(cannonball_entity, x)
YPosition.add(cannonball_entity, y)
XVelocity.add(cannonball_entity, 0)
YVelocity.add(cannonball_entity, 0)
ImageFile.add(cannonball_entity, "cannonball.svg")
ProjectileTarget.add(cannonball_entity, target)
ProjectileDamage.add(cannonball_entity, attack_damage)
end
...
end
Notice we start the velocity at zero, because the movement will be entirely handled by the Projectile
system:
defmodule Ship.Systems.Projectile do
...
@behaviour ECSx.System
alias Ship.Components.ArmorRating
alias Ship.Components.HullPoints
alias Ship.Components.ImageFile
alias Ship.Components.IsProjectile
alias Ship.Components.ProjectileDamage
alias Ship.Components.ProjectileTarget
alias Ship.Components.XPosition
alias Ship.Components.XVelocity
alias Ship.Components.YPosition
alias Ship.Components.YVelocity
@cannonball_speed 3
@impl ECSx.System
def run do
projectiles = IsProjectile.get_all()
Enum.each(projectiles, fn projectile ->
case ProjectileTarget.get(projectile, nil) do
nil ->
# The target has already been destroyed
destroy_projectile(projectile)
target ->
continue_seeking_target(projectile, target)
end
end)
end
defp continue_seeking_target(projectile, target) do
{dx, dy, distance} = get_distance_to_target(projectile, target)
case distance do
0 ->
collision(projectile, target)
distance when distance / @cannonball_speed <= 1 ->
move_directly_to_target(projectile, {dx, dy})
distance ->
adjust_velocity_towards_target(projectile, {distance, dx, dy})
end
end
defp get_distance_to_target(projectile, target) do
target_x = XPosition.get(target)
target_y = YPosition.get(target)
target_dx = XVelocity.get(target)
target_dy = YVelocity.get(target)
target_next_x = target_x + target_dx
target_next_y = target_y + target_dy
x = XPosition.get(projectile)
y = YPosition.get(projectile)
dx = target_next_x - x
dy = target_next_y - y
{dx, dy, ceil(:math.sqrt(dx ** 2 + dy ** 2))}
end
defp collision(projectile, target) do
damage_target(projectile, target)
destroy_projectile(projectile)
end
defp damage_target(projectile, target) do
damage = ProjectileDamage.get(projectile)
reduction_from_armor = ArmorRating.get(target)
final_damage_amount = damage - reduction_from_armor
target_current_hp = HullPoints.get(target)
target_new_hp = target_current_hp - final_damage_amount
HullPoints.update(target, target_new_hp)
end
defp destroy_projectile(projectile) do
IsProjectile.remove(projectile)
XPosition.remove(projectile)
YPosition.remove(projectile)
XVelocity.remove(projectile)
YVelocity.remove(projectile)
ImageFile.remove(projectile)
ProjectileTarget.remove(projectile)
ProjectileDamage.remove(projectile)
end
defp move_directly_to_target(projectile, {dx, dy}) do
XVelocity.update(projectile, dx)
YVelocity.update(projectile, dy)
end
defp adjust_velocity_towards_target(projectile, {distance, dx, dy}) do
# We know what is needed, but we need to slow it down, so its travel
# will take more than one tick. Otherwise the player will not see it!
ticks_away = ceil(distance / @cannonball_speed)
adjusted_dx = div(dx, ticks_away)
adjusted_dy = div(dy, ticks_away)
XVelocity.update(projectile, adjusted_dx)
YVelocity.update(projectile, adjusted_dy)
end
end
Note that we rely on the absence of a ProjectileTarget
to know that the target is already destroyed. Currently our Destruction
system does have an untarget
feature for removing target components upon destruction, but this only applies to AttackTarget
s. We'll want to expand this feature to also cover ProjectileTarget
s:
defmodule Ship.Systems.Destruction do
...
alias Ship.Components.ProjectileTarget
...
defp untarget(target) do
for ship <- AttackTarget.search(target) do
AttackTarget.remove(ship)
SeekingTarget.add(ship)
end
for projectile <- ProjectileTarget.search(target) do
ProjectileTarget.remove(projectile)
end
end
end
Our final task is to render these projectiles in the LiveView. Let's start by adding a new assign:
defmodule Ship.GameLive do
...
alias Ship.Components.IsProjectile
...
defp assign_loading_state(socket) do
assign(socket,
...
projectiles: []
)
end
def handle_info(:first_load, socket) do
...
socket =
socket
|> assign_player_ship()
|> assign_other_ships()
|> assign_projectiles()
|> assign_offsets()
|> assign(loading: false)
...
end
def handle_info(:refresh, socket) do
socket =
socket
|> assign_player_ship()
|> assign_other_ships()
|> assign_projectiles()
|> assign_offsets()
...
end
...
defp assign_projectiles(socket) do
projectiles =
for projectile <- IsProjectile.get_all() do
x = XPosition.get(projectile)
y = YPosition.get(projectile)
image = ImageFile.get(projectile)
{projectile, x, y, image}
end
assign(socket, projectiles: projectiles)
end
...
end
Then we'll update the render to include the projectiles:
defmodule Ship.GameLive do
def render(assigns) do
~H"""
...
<%= for {_entity, x, y, image_file} <- @projectiles do %>
<image
x={x}
y={y}
width="1"
height="1"
href={~p"/images/#{image_file}"}
/>
<% end %>
<%= for {_entity, x, y, image_file} <- @other_ships do %>
...
"""
end
end
Lastly - cannonball.svg
- we will make this file from scratch!
$ touch priv/static/images/cannonball.svg
<svg width="100" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="25" stroke="black" fill="gray" stroke-width="5" />
</svg>
The width
and height
values will be overriden by our LiveView render's width="1" height="1"
, but they still play an important role - because the circle's parameters will be measured relative to these - so we'll set them to 100
for simplicity. cx
and cy
represent the coordinates for the center of the circle, which should be one-half the width
and height
. The size of the circle will be set with r
(radius) and stroke-width
(the border around the circle) - we can calculate diameter = 2 * (r + stroke_width) = 60
. This diameter is also relative, so when our cannonball.svg
is scaled down to 1 x 1
, the visible circle will be 0.6 x 0.6
limiting-ship-movement
Limiting Ship Movement
You might notice that that once the ship hits the edge of the world map it keeps moving and disappears from sight. This happens because we keep updating the ship's position regardless of it being within the limits of the map.
To solve this, let's go to the Driver
system and limit the position range for the player ship:
defmodule Ship.Systems.Driver do
...
def run do
for {entity, x_velocity} <- XVelocity.get_all() do
...
new_x_position = calculate_new_position(x_position, x_velocity)
...
end
for {entity, y_velocity} <- YVelocity.get_all() do
...
new_y_position = calculate_new_position(y_position, y_velocity)
...
end
...
end
# Do not let player ship move past the map limit
defp calculate_new_position(current_position, velocity) do
new_position = current_position + velocity
new_position = Enum.min([new_position, 99])
Enum.max([new_position, 0])
end
end
This will limit the position for the ship on both X and Y axis to be between 0 and 99 (the size of the 100x100 world map).