@@ -0,0 +1,290 @@
const bg = "black";

type Ctx = CanvasRenderingContext2D;
type Image = HTMLImageElement;

function noop() { }

const gutter = 0.1;
const unit = 1 - gutter * 2;
const foodColor = "red";
const coordCache: WeakMap<bs.Snake, Array<bs.Point>> = new WeakMap();

function add([x0, x1]: bs.Point, [y0, y1]: bs.Point): bs.Point {
return [x0 + y0, x1 + y1];
}

function mul([x0, x1]: bs.Point, s: number): bs.Point {
return [x0 * s, x1 * s];
}

function div(a: bs.Point, s: number): bs.Point {
return mul(a, 1 / s)
}

function sub(a: bs.Point, b: bs.Point): bs.Point {
return add(a, mul(b, -1))
}

function eq(a: bs.Point, b: bs.Point): boolean {
return a[0] === b[0] && a[1] === b[1];
}

function uniq(s: bs.Point[]): bs.Point[] {
return s.reduce(([y, ...s], x) => {
if (!eq(y, x)) {
return [x, y, ...s];
}
return [y, ...s];
}, [s[0]])
.reverse();
}

function coords(snake: bs.Snake): bs.Point[] {
const coords = coordCache.get(snake)

if (coords) {
return coords;
}

const arr = uniq(snake.coords);

coordCache.set(snake, arr);

return arr;
}

// Add an interpolated point between every point in points.
function smooth(points: bs.Point[]) {
return points.reduce(
([b, ...s], a) => {
const mean = div(add(a, b), 2);
return [a, mean, b, ...s];
},
[points[0]]);
}

function get(href: string): Promise<SVGSVGElement> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();

request.open("GET", href);

request.addEventListener("load", (event: ProgressEvent) => {
const request = <XMLHttpRequest>event.currentTarget;

const xml = request.responseXML;

if (!xml || !xml.children[0]) {
return reject('no xml data');
}

return resolve(<SVGSVGElement>xml.children[0]);
});

request.send();
});
}

function svg2image(svg: SVGSVGElement, color: string) {
svg.setAttribute('fill', color)

const DOMURL = window.URL || window;

const image = new Image();

const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });

const url = DOMURL.createObjectURL(blob);

image.src = url

return image
}

function loadImage(id: string, color: string): Promise<Image> {
const link = <HTMLLinkElement | null>document.getElementById(id);

if (!link || !link.href) {
return Promise.reject('no href on link');
}

return get(link.href).then((svg: SVGSVGElement) => {
return svg2image(svg, color);
});
}

export class Board {
private readonly bgctx: Ctx;
private readonly fgctx: Ctx;
private readonly height: number;
private readonly width: number;
private readonly images = new Map();

constructor(fgctx: Ctx, bgctx: Ctx, width: number, height: number) {
this.bgctx = bgctx;
this.fgctx = fgctx;
this.width = width;
this.height = height;
}

drawGrid(width: number, height: number) {
this.clear(this.bgctx);

this.bgctx.fillStyle = bg;

for (let i = 0; i < width; i++) {
for (let j = 0; j < height; j++) {
this.bgctx.fillRect(i + gutter, j + gutter, unit, unit);
}
}

delete this.drawGrid;
this.drawGrid = noop;
}

getImage(id: string, color: string): Image {
const key = `${id}-${color}`

const image = this.images.get(key);

if (image) {
return image;
}

loadImage(id, color).then((image: Image) => {
this.images.set(key, image);
});

return new Image();
}

headImage(snake: bs.Snake): Image {
const id = `snake-head-${snake.headType}`;
return this.getImage(id, snake.color);
}

tailImage(snake: bs.Snake): Image {
const id = `snake-tail-${snake.tailType}`;
return this.getImage(id, snake.color);
}

drawSnakeBody(snake: bs.Snake) {
const coordinates = smooth(coords(snake)).slice(1, -2);

if (coordinates.length < 4) {
return;
}

const [head, ...rest] = coordinates;

const ctx = this.fgctx;

ctx.save();

ctx.translate(0.5, 0.5);

ctx.beginPath();

ctx.strokeStyle = snake.color;

ctx.lineWidth = unit;

ctx.moveTo(head[0], head[1]);

for (let i = 0; i < (rest.length - 1); i += 2) {
const c = rest[i];
const x = rest[i + 1];
ctx.quadraticCurveTo(c[0], c[1], x[0], x[1]);
}

ctx.stroke();

ctx.restore();
}

drawImages(snake: bs.Snake) {
const coordinates = coords(snake);

const [h0, h1] = coordinates;
const [t1, t0] = coordinates.slice(-2);

const head = this.headImage(snake);
const tail = this.tailImage(snake);

// h1 or t0 may be nil.
this.drawImage(head, h0, h1 || h0);
this.drawImage(tail, t0 || t1, t1);
}

drawImage(image: Image, h0: bs.Point, h1: bs.Point) {
const ctx = this.fgctx;

const offset = unit / 2 * -1;

const v = sub(h0, h1);

const [a0, a1] = add([0.5, 0.5], // translate to centre of point
sub(h0, // move to coordinate
div(v, 10))); // move to border of path

ctx.save();

ctx.translate(a0, a1);

switch (v.join(' ')) {
case '0 -1':
ctx.rotate(-Math.PI / 2);
break;

case '0 1':
ctx.rotate(Math.PI / 2);
break;

case '-1 0':
ctx.scale(-1, 1);
break;
}

ctx.drawImage(image, offset, offset, unit, unit);

ctx.restore();
}

drawFood([x, y]: bs.Food) {
const ctx = this.fgctx;
ctx.beginPath();
ctx.fillStyle = foodColor;
ctx.arc(x, y, unit / 2, 0, 2 * Math.PI);
ctx.fill();
}

clear(ctx: Ctx) {
ctx.clearRect(0, 0, this.width, this.height);
}

draw(board: bs.Board) {
const ctxs = [this.bgctx, this.fgctx];

const width = Math.floor(this.width / board.width);
const height = Math.floor(this.height / board.height);

this.clear(this.fgctx);

ctxs.forEach(x => x.scale(width, height));

this.drawGrid(width, height);

board.snakes.forEach((snake: bs.Snake) => this.drawSnakeBody(snake));

this.fgctx.translate(0.5, 0.5);

board.food.forEach((food: bs.Point) => this.drawFood(food));

this.fgctx.translate(-0.5, -0.5);

board.snakes.forEach((snake: bs.Snake) => this.drawImages(snake));

ctxs.forEach(x => x.setTransform(1, 0, 0, 1, 0, 0));
}
}
@@ -0,0 +1 @@
export * from "./spectator";
@@ -0,0 +1,31 @@
import socket from "../socket";

type OnTick = (board: bs.Board) => void;

export class Spectator {
private gameId: string;

onTick: OnTick;

logger = console.error.bind(console);

constructor(gameId: string) {
this.gameId = gameId;
}

get name() {
return `spectator:${this.gameId}`;
}

join() {
const channel = socket.channel(this.name, {});

channel.on("tick", ({ content }: bs.TickResponse) => {
this.onTick(content);
});

channel.join().receive("error", this.logger);
}
}

export default Spectator;

This file was deleted.

@@ -4,8 +4,7 @@
"module": "ESNext",
"lib": [
"dom",
"esnext",
"node"
"esnext"
],
"allowJs": true,
"checkJs": true,
@@ -1,48 +1,10 @@
defimpl Poison.Encoder, for: BattleSnake.Snake do
def encode(snake, opts) do
case Keyword.pop(opts, :mode) do
{:consumer, opts} ->
consumer_encode(snake, opts)

{_, opts} ->
do_encode(snake, opts)
end
end

def do_encode(snake, opts) do
keys = [:coords, :id, :taunt, :health_points, :name]
snake
|> Map.take(keys)
|> Poison.encode!(opts)
end

def consumer_encode(snake, opts) do
alias BattleSnake.Death
keys = [:coords, :id, :taunt, :health_points, :name, :head_url, :color, :cause_of_death]

# get the cause_of_death text, from the struct
cause_of_death =
case snake.cause_of_death do
%Death.StarvationCause{} ->
"Starved to death"
%Death.WallCollisionCause{} ->
"Crashed into a wall"
%Death.SelfCollisionCause{} ->
"Collided with itself"
%Death.BodyCollisionCause{} ->
"Collided with another snake's body"
%Death.HeadCollisionCause{} ->
"Consumed by another snake"
_ ->
""
end

snake = Map.put(snake, :cause_of_death, cause_of_death)

snake
|> Map.take(keys)
|> Poison.encode!(opts)
end
end

defmodule BattleSnake.Snake do
@@ -7,11 +7,7 @@ defmodule BattleSnakeWeb.SpectatorChannel do

@type join_payload :: %{optional(binary) => binary}
@spec join(binary, join_payload, Phoenix.Socket.t) :: {:ok, Phoenix.Socket} | {:error, any}
def join("spectator:html:" <> game_id, payload, socket) do
do_join(game_id, payload, socket)
end

def join("spectator:json:" <> game_id, payload, socket) do
def join("spectator:" <> game_id, payload, socket) do
do_join(game_id, payload, socket)
end

@@ -33,8 +29,8 @@ defmodule BattleSnakeWeb.SpectatorChannel do
####################

def handle_info(%GameStateEvent{name: _name, data: state}, socket) do
content = render_content(content_type(socket), state)
broadcast(socket, "tick", %{content: content})
broadcast(socket, "tick", %{content: render(state.world)})

{:noreply, socket}
end

@@ -47,8 +43,8 @@ defmodule BattleSnakeWeb.SpectatorChannel do
|> GameServer.find!
|> GameServer.get_game_state

content = render_content(content_type(socket), state)
push(socket, "tick", %{content: content})
push(socket, "tick", %{content: render(state.world)})

{:noreply, socket}
end

@@ -61,19 +57,11 @@ defmodule BattleSnakeWeb.SpectatorChannel do
true
end

defp render_content("json", state) do
Poison.encode!(state.world, mode: :consumer)
end

defp render_content(_, state) do
BattleSnakeWeb.SpectatorView
|> Phoenix.View.render_to_string("board.html", state: state)
|> String.replace(~r/^\s+|\s+$/m, "")
|> String.replace(~r/\n+/m, " ")
end

defp content_type(socket) do
[_, type|_] = String.split(socket.topic, ":")
type
defp render(board) do
Phoenix.View.render(
BattleSnakeWeb.BoardView,
"show.json",
board: board
)
end
end
@@ -3,8 +3,8 @@ defmodule BattleSnakeWeb.UserSocket do

## Channels
# channel "room:*", BattleSnake.RoomChannel
channel "spectator:json:*", BattleSnakeWeb.SpectatorChannel
channel "spectator:html:*", BattleSnakeWeb.SpectatorChannel
channel "spectator:*", BattleSnakeWeb.SpectatorChannel
channel "spectator:*", BattleSnakeWeb.SpectatorChannel
channel "game_admin:*", BattleSnakeWeb.GameAdminChannel
channel "replay:html:*", BattleSnakeWeb.ReplayChannel
channel "replay:json:*", BattleSnakeWeb.ReplayChannel
@@ -7,6 +7,5 @@
<% end %>

<%= link "Play", to: play_path(@conn, :show, @game) %>
<%= link "Finals", to: skin_path(@conn, :show, @game) %>
<%= link "Back", to: game_path(@conn, :index) %>
</section>
@@ -21,7 +21,6 @@

<td class="text-right">
<%= link "Play", to: play_path(@conn, :show, game), class: "btn btn-default btn-xs" %>
<%= link "Finals", to: skin_path(@conn, :show, game), class: "btn btn-default btn-xs" %>
<%= link "Show", to: game_path(@conn, :show, game), class: "btn btn-default btn-xs" %>
<%= link "Edit", to: game_path(@conn, :edit, game), class: "btn btn-default btn-xs" %>
<%= link "Delete", to: game_path(@conn, :delete, game), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %>
@@ -5,6 +5,5 @@
<%= inspect @game %>

<%= link "Play", to: play_path(@conn, :show, @game) %>
<%= link "Finals", to: skin_path(@conn, :show, @game) %>
<%= link "Edit", to: game_path(@conn, :edit, @game) %>
<%= link "Back", to: game_path(@conn, :index) %>
@@ -15,10 +15,6 @@
<%= render @view_module, @view_template, assigns %>
</main>

<script>
window.BattleSnake = <%= raw battle_snake_js_object(assigns) %>;
</script>

<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>

This file was deleted.

@@ -1,2 +1,17 @@
<%= render "_modal_keybinds.html" %>
<div id="board-viewer" class="play-show"></div>
<%= for asset <- snake_assets do %>
<link
id="<%= asset[:id] %>"
href="<%= static_path(@conn, "/#{asset[:src]}") %>"
charset="utf-8"
rel="prefetch"
type="image/svg+xml"
></link>
<% end %>

<script id="battle-snake-config" type="application/json">
<%= raw battle_snake_js_object(assigns) %>
</script>

<div class="play-show">
<div id="game-board" style="flex:1;"></div>
</div>

This file was deleted.

@@ -0,0 +1,12 @@
defmodule BattleSnakeWeb.BoardConfigView do
alias BattleSnakeWeb.GameAdminChannel

use BattleSnakeWeb, :view

def render("show.json", %{game: game}) do
%{
gameId: game.id,
gameAdminAvailableRequests: GameAdminChannel.available_requests()
}
end
end
@@ -0,0 +1,35 @@
defmodule BattleSnakeWeb.BoardView do
use BattleSnakeWeb, :view

def render("snake.json", %{snake: snake}) do
%{
health: snake.health_points,
coords: snake.coords,
color: snake.color,
id: snake.id,
name: snake.name,
taunt: snake.taunt,
headType: snake.head_type,
tailType: snake.tail_type,
}
end

def render("show.json", %{board: board}) do
snakes = &Phoenix.View.render_many(
&1,
__MODULE__,
"snake.json",
as: :snake
)

%{
width: board.width,
height: board.height,
gameId: board.game_id,
turn: board.turn,
food: board.food,
deadSnakes: snakes.(board.dead_snakes),
snakes: snakes.(board.snakes)
}
end
end
@@ -2,24 +2,4 @@ defmodule BattleSnakeWeb.LayoutView do
alias BattleSnakeWeb.GameAdminChannel

use BattleSnakeWeb, :view

def battle_snake_js_object(assigns, acc \\ %{})

def battle_snake_js_object(%{is_replay: is_replay} = h, acc) do
h
|> Map.delete(:is_replay)
|> battle_snake_js_object(put_in(acc[:isReplay], is_replay))
end

def battle_snake_js_object(%{game: game} = h, acc) do
h
|> Map.delete(:game)
|> battle_snake_js_object(put_in(acc[:gameId], game.id))
end

def battle_snake_js_object(_, acc) do
s = GameAdminChannel.available_requests()
put_in(acc[:gameAdminAvailableRequests], s)
|> Poison.encode!
end
end
@@ -24,9 +24,9 @@ defmodule BattleSnakeWeb.PlayView do
def snake_img_tags(conn) do
snake_assets()
|> Enum.map(fn asset ->
content_tag(:script, "",
content_tag(:object, "",
id: asset[:id],
src: static_path(conn, "/#{asset[:src]}"),
data: static_path(conn, "/#{asset[:src]}"),
charset: "utf-8",
type: "image/svg+xml")
end)

This file was deleted.

@@ -8,85 +8,21 @@ defmodule BattleSnakeWeb.SpectatorChannelTest do
describe "SpectatorChannel" do
setup [:sub, :broadcast_state]

@tag content_type: "html"
test "relays broadcasts to clients" do
assert_broadcast "tick", %{content: _}
end

@tag content_type: "html"
test "renders html" do
assert_broadcast "tick", %{content: content}
content =~ ~r/<svg>/
end

@tag content_type: "json"
test "renders json" do
test "renders content" do
assert_broadcast "tick", %{content: content}
assert {:ok, board} = Poison.decode content
assert is_list(board["food"]), "food is not in board: #{inspect board}"
assert is_list(board["snakes"])
end
end

test "channels only get the content type they subscribed to" do
game_form = create(:game_form)
id = game_form.id
channel = SpectatorChannel
caller = self()

spawn_link fn ->
topic = "spectator:json:#{id}"
{:ok, _, _} = "user-1"
|> socket(%{})
|> subscribe_and_join(channel, topic)

forward_msg = fn f ->
receive do
x -> send(caller, {:json, x}) && f.(f)
end
end

forward_msg.(forward_msg)
end

spawn_link fn ->
topic = "spectator:html:#{id}"
{:ok, _, _} = "user-2"
|> socket(%{})
|> subscribe_and_join(channel, topic)

forward_msg = fn f ->
receive do
x -> send(caller, {:html, x}) && f.(f)
end
end

forward_msg.(forward_msg)
end

broadcast_state(%{id: id})

assert_receive {:json, %Phoenix.Socket.Message{event: "tick", payload: %{content: "{" <> _ }}}
assert_receive {:html, %Phoenix.Socket.Message{event: "tick", payload: %{content: "<div" <> _ }}}

assert_receive {:json, %Phoenix.Socket.Broadcast{event: "tick", payload: %{content: "{" <> _ }}}
assert_receive {:html, %Phoenix.Socket.Broadcast{event: "tick", payload: %{content: "<div" <> _ }}}

refute_receive {:json, %Phoenix.Socket.Broadcast{event: "tick", payload: %{content: "<div" <> _ }}}
refute_receive {:html, %Phoenix.Socket.Broadcast{event: "tick", payload: %{content: "{" <> _ }}}
end

def broadcast_state(c) do
state = build(:state)
GameServer.PubSub.broadcast(c.id, %GameStateEvent{name: "test", data: state})
end

def sub c do
id = create(:game_form).id
content_type = c.content_type
{:ok, _, socket} =
socket("user_id", %{})
|> subscribe_and_join(SpectatorChannel, "spectator:#{content_type}:#{id}")
|> subscribe_and_join(SpectatorChannel, "spectator:#{id}")

[socket: socket, id: id]
end