///
const ws = new WebSocket("/events")
type Packet = "tick"
| { pos: { id: number, x: number, y: number } }
| { game: { width: number, height: number } }
| { player: { id: number, name: string } }
| { die: number[] }
| { win: [number, number] }
| { message: { id: number, message: string } }
class Snake {
parts: { x: number, y: number, dx: number, dy: number }[] = []
color: string
dead = false
constructor(public name: string) {
this.color = name_color(name)
}
add_part(x: number, y: number) {
if (!this.parts.length) return this.parts.push({ x, y, dx: 0, dy: 0 })
const last = this.parts[this.parts.length - 1]
let dx = x - last.x, dy = y - last.y;
if (x > last.x + 1 || x < last.x - 1) dx *= -1, dx /= Math.abs(dx)
if (y > last.y + 1 || y < last.y - 1) dy *= -1, dy /= Math.abs(dy)
this.parts.push({ x, y, dx, dy })
}
}
let size = 0
let tick_anim = 0;
const snakes = new Map()
let canvas: HTMLCanvasElement
let ctx: CanvasRenderingContext2D
document.addEventListener("DOMContentLoaded", () => {
canvas = document.createElement("canvas")
ctx = canvas.getContext("2d")!
document.getElementById("board")?.append(canvas)
canvas.width = 1000;
canvas.height = 1000;
redraw()
})
function redraw() {
ctx.fillStyle = "black"
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save()
const scale = canvas.width / size
ctx.scale(scale, scale)
for (let x = 0; x < size; x++) {
for (let y = 0; y < size; y++) {
ctx.strokeStyle = "grey"
ctx.lineWidth = 0.02
ctx.strokeRect(x, y, 1, 1)
}
}
ctx.translate(0.5, 0.5)
for (const [xo, yo] of [[0, 0], [-size, 0], [size, 0], [0, -size], [0, size]]) {
ctx.save()
ctx.translate(xo, yo)
for (const snake of snakes.values()) {
ctx.beginPath();
for (let i = 0; i < snake.parts.length; i++) {
const p = snake.parts[i];
ctx.moveTo(p.x - p.dx, p.y - p.dy)
if (i == snake.parts.length - 1)
ctx.lineTo(tick_lerp(p.x - p.dx, p.x), tick_lerp(p.y - p.dy, p.y))
else ctx.lineTo(p.x, p.y)
}
ctx.lineCap = "round"
ctx.lineJoin = "round"
if (snake.dead) ctx.lineWidth = tick_lerp(0.6, 0.001)
else ctx.lineWidth = 0.6;
ctx.strokeStyle = snake.color
ctx.stroke()
}
ctx.restore()
}
ctx.restore()
ctx.save()
ctx.translate(scale / 2, scale / 4)
for (const [xo, yo] of [[0, 0], [-size * scale, 0], [size * scale, 0], [0, -size * scale], [0, size * scale]]) {
ctx.save()
ctx.translate(xo, yo)
for (const snake of snakes.values()) {
const p = snake.parts[snake.parts.length - 1];
if (!p) continue
ctx.font = "30px sans-serif"
if (snake.dead) ctx.fillStyle = `rgba(1,1,1,${tick_lerp(1, 0)})`
else ctx.fillStyle = "white"
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText(snake.name, tick_lerp(p.x - p.dx, p.x) * scale, tick_lerp(p.y - p.dy, p.y) * scale)
}
ctx.restore()
}
ctx.restore()
tick_anim += 0.1
tick_anim = Math.min(1, tick_anim)
requestAnimationFrame(redraw)
}
function tick_lerp(f: number, t: number) {
return (1 - tick_anim) * f + tick_anim * t
}
function name_color(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash += name.charCodeAt(i);
hash |= 0;
hash ^= hash << 13;
hash ^= hash >> 17;
hash ^= hash << 5;
}
return `hsl(${hash % 360}deg 80% 40%)`;
}
ws.onerror = console.error
ws.onmessage = message => {
const p = JSON.parse(message.data) as Packet
packet_queue.push(p)
if (p == "tick") process_packets()
else if ("message" in p) {
chat(`${snakes.get(p.message.id)?.name}: ${p.message.message}`)
}
}
let packet_queue: Packet[] = []
function process_packets() {
for (const p of packet_queue) {
if (p == "tick") {
tick_anim = 0
const d = []
for (const [k, s] of snakes) {
if (s.dead) d.push(k)
}
d.forEach(k => snakes.delete(k))
} else if ("game" in p) {
snakes.clear()
update_stats()
size = p.game.width
} else if ("win" in p) {
const winner = snakes.get(p.win[0])?.name;
if (winner) chat(`${winner} won this round.`, "win")
else chat(`round ended in a tie.`, "tie")
} else if ("player" in p) {
snakes.set(p.player.id, new Snake(p.player.name))
} else if ("pos" in p) {
snakes.get(p.pos.id)?.add_part(p.pos.x, p.pos.y)
} else if ("die" in p) {
chat(`${p.die.map(e => snakes.get(e)?.name).join(", ")} died.`, "die")
for (const d of p.die) {
const s = snakes.get(d)
if (s) s.dead = true
}
}
}
packet_queue = []
}
const chat_history: HTMLElement[] = [];
function chat(message: string, clas = "chat") {
const e = document.createElement("p")
e.textContent = message;
e.classList.add("message")
e.classList.add(clas)
document.getElementById("chat_messages")!.prepend(e)
chat_history.push(e);
while (chat_history.length > 64) {
chat_history.splice(0, 1).forEach(e => e.remove())
}
}
async function update_stats() {
const res = await fetch("/stats");
const scoreboard = await res.json() as [string, number][]
const sce = document.getElementById("scoreboard_inner")!
sce.innerHTML = ""
for (const [name, score] of scoreboard) {
const e = document.createElement("li")
e.textContent = `${name}: ${score}`
sce.append(e)
}
}