///
import { Gamedata, ItemIndex, PacketC, PacketS, PlayerID, TileIndex } from "./protocol.ts";
import { FALLBACK_ITEM } from "./tiles.ts";
import { FALLBACK_TILE, ITEMS, TILES } from "./tiles.ts";
import { V2, add_v2, ceil_v2, floor_v2, length, lerp_exp_v2_mut, normalize, aabb_circle_distance } from "./util.ts";
const PLAYER_SIZE = 0.4;
let ctx: CanvasRenderingContext2D;
let canvas: HTMLCanvasElement;
let ws: WebSocket;
document.addEventListener("DOMContentLoaded", () => {
const ws_uri = window.location.protocol.endsWith("s:")
? `wss://backend-${window.location.hostname}/`
: `ws://${window.location.hostname}:27032/`
ws = new WebSocket(ws_uri)
ws.onerror = console.error
ws.onmessage = m => {
packet(JSON.parse(m.data) as PacketC);
}
ws.onclose = () => console.log("close")
ws.onopen = () => {
console.log("open")
send({ type: "join", name: "test", character: Math.floor(Math.random() * 255) })
}
canvas = document.createElement("canvas");
document.body.append(canvas)
ctx = canvas.getContext("2d")!
resize()
globalThis.addEventListener("resize", resize)
draw()
document.addEventListener("keydown", ev => keyboard(ev, true))
document.addEventListener("keyup", ev => keyboard(ev, false))
document.addEventListener("contextmenu", ev => ev.preventDefault())
setInterval(tick_update, 1000 / 25);
})
interface ItemData {
kind: ItemIndex,
x: number,
y: number,
tracking?: V2,
progress?: number
remove_anim?: number
}
interface PlayerData {
x: number,
y: number,
name: string,
rot: number,
item?: ItemData,
facing: V2,
character: number,
vel: { x: number, y: number }
}
interface TileData {
x: number
y: number
kind: TileIndex
item?: ItemData
}
const players = new Map()
const tiles = new Map()
const items_removed = new Set()
let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0] }
let my_id: PlayerID = -1
const camera: V2 = { x: 0, y: 0 }
let camera_zoom = 0.1
const interact_target_anim: V2 = { x: 0, y: 0 }
let interacting: V2 | undefined;
let scale = 0
function send(p: PacketS) { ws.send(JSON.stringify(p)) }
function packet(p: PacketC) {
if (!["position", "set_active"].includes(p.type))
console.log(p);
switch (p.type) {
case "init":
my_id = p.id
data = p.data
break;
case "add_player": {
let item = undefined
if (p.item) item = { kind: p.item, x: 0, y: 0 };
players.set(p.id, {
x: p.position[0],
y: p.position[1],
character: p.character,
name: p.name,
rot: 0,
item,
facing: { x: 0, y: 1 },
vel: { x: 0, y: 0 },
})
break;
}
case "remove_player":
players.delete(p.id)
break;
case "position": {
if (p.player == my_id) return; // we know better where we are
const pl = players.get(p.player)!
pl.x = p.pos[0]
pl.y = p.pos[1]
pl.rot = p.rot
break;
}
case "take_item": {
const player = players.get(p.player)!
const tile = tiles.get(p.tile.toString())!
player.item = tile.item;
player.item!.tracking = player
tile.item = undefined
break;
}
case "put_item": {
const player = players.get(p.player)!
const tile = tiles.get(p.tile.toString())!
tile.item = player.item
tile.item!.tracking = add_v2(tile, 0.5)
player.item = undefined
break;
}
case "produce_item": {
const item = { kind: p.item, x: p.tile[0] + 0.5, y: p.tile[1] + 0.5 }
tiles.get(p.tile.toString())!.item = item
break;
}
case "consume_item": {
const tile = tiles.get(p.tile.toString())!
if (tile.item) items_removed.add(tile.item)
tile.item = undefined
break;
}
case "set_active": {
tiles.get(p.tile.toString())!.item!.progress = p.progress
break;
}
case "update_map":
tiles.set(p.pos.toString(), { x: p.pos[0], y: p.pos[1], kind: p.tile })
break;
default:
console.warn("unknown packet", p);
}
}
const keys_down = new Set();
const HANDLED_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "Space"]
function keyboard(ev: KeyboardEvent, down: boolean) {
if (HANDLED_KEYS.includes(ev.code)) ev.preventDefault()
if (down) keys_down.add(ev.code)
else keys_down.delete(ev.code)
}
function get_interact_target(): V2 | undefined {
const me = players.get(my_id)
if (!me) return
return {
x: Math.floor(me.x + Math.sin(me.rot)),
y: Math.floor(me.y + Math.cos(me.rot))
}
}
function tick_update() {
const p = players.get(my_id)
if (!p) return
send({ type: "position", pos: [p.x, p.y], rot: p.rot })
const { x, y } = get_interact_target()!;
if (interacting && !keys_down.has("Space")) {
send({ type: "interact", pos: [interacting.x, interacting.y], edge: false })
interacting = undefined;
}
if (keys_down.has("Space") && x != interacting?.x && y != interacting?.y) {
if (interacting)
send({ type: "interact", pos: [interacting.x, interacting.y], edge: false })
send({ type: "interact", pos: [x, y], edge: true })
interacting = { x, y }
}
}
function frame_update(dt: number) {
const p = players.get(my_id)
if (!p) return
const input = normalize({
x: (+keys_down.has("KeyD") - +keys_down.has("KeyA")),
y: (+keys_down.has("KeyS") - +keys_down.has("KeyW"))
})
if (length(input) > 0.1) lerp_exp_v2_mut(p.facing, input, dt * 10.)
p.rot = Math.atan2(p.facing.x, p.facing.y)
p.vel.x += input.x * dt * 0.5
p.vel.y += input.y * dt * 0.5
p.x += p.vel.x
p.y += p.vel.y
collide_player(p)
lerp_exp_v2_mut(p.vel, { x: 0, y: 0 }, dt * 5.)
const update_item = (item: ItemData) => {
if (item.tracking) lerp_exp_v2_mut(item, item.tracking, dt * 10.)
}
for (const [_, player] of players) {
if (player.item) update_item(player.item)
}
for (const [_, tile] of tiles) {
if (tile.item) update_item(tile.item)
}
const remove = []
for (const item of items_removed) {
update_item(item)
if (item.remove_anim === undefined) item.remove_anim = 0
item.remove_anim += dt * 4.
if (item.remove_anim > 1.) remove.push(item)
}
remove.forEach(i => items_removed.delete(i))
lerp_exp_v2_mut(interact_target_anim, get_interact_target() ?? { x: 0, y: 0 }, dt * 15.)
lerp_exp_v2_mut(camera, p, dt * 10.)
}
function resize() {
canvas.width = globalThis.innerWidth
canvas.height = globalThis.innerHeight
}
let last_frame = performance.now()
function draw() {
const now = performance.now()
frame_update((now - last_frame) / 1000)
last_frame = now;
if (ws.readyState == ws.CONNECTING) draw_wait("Connecting...")
else if (ws.readyState == ws.CLOSING) draw_wait("Closing...")
else if (ws.readyState == ws.CLOSED) draw_wait("Disconnected")
else if (ws.readyState == ws.OPEN) draw_ingame()
else throw new Error(`ws state invalid`);
requestAnimationFrame(draw)
}
function draw_wait(text: string) {
ctx.fillStyle = "#444"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = "#555"
ctx.font = "50px sans-serif"
ctx.strokeStyle = "black"
ctx.fillStyle = "white"
ctx.lineWidth = 10
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.lineJoin = "round"
ctx.lineCap = "round"
ctx.strokeText(text, canvas.width / 2, canvas.height / 2)
ctx.fillText(text, canvas.width / 2, canvas.height / 2)
}
function map_screen_to_world(screen: V2): V2 {
return {
x: ((screen.x - canvas.width / 2) / scale) + camera.x,
y: ((screen.y - canvas.height / 2) / scale) + camera.y,
}
}
function draw_ingame() {
ctx.fillStyle = "#111"
ctx.fillRect(0, 0, canvas.width, canvas.height)
scale = Math.min(canvas.width, canvas.height) * camera_zoom;
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.scale(scale, scale)
ctx.translate(-camera.x, -camera.y)
draw_grid()
for (const [_, tile] of tiles) {
ctx.save()
ctx.translate(tile.x + 0.5, tile.y + 0.5)
const comps = TILES[data.tile_names[tile.kind]] ?? FALLBACK_TILE
for (const c of comps) {
c(ctx)
}
ctx.restore()
}
for (const [_, player] of players) {
ctx.save()
ctx.translate(player.x, player.y)
ctx.rotate(-player.rot)
ctx.fillStyle = `hsl(${player.character}rad, 50%, 50%)`
ctx.beginPath()
ctx.arc(0, 0, PLAYER_SIZE, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = `hsl(${player.character}rad, 80%, 10%)`
ctx.beginPath()
ctx.arc(0, -0.2, PLAYER_SIZE, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = `hsl(${player.character}rad, 80%, 70%)`
ctx.beginPath()
ctx.moveTo(-0.04, 0.25)
ctx.lineTo(0.04, 0.25)
ctx.lineTo(0, 0.4)
ctx.fill()
ctx.restore()
if (player.item) draw_item(player.item)
}
for (const item of items_removed) {
draw_item(item)
}
for (const [_, tile] of tiles) {
if (tile.item) draw_item(tile.item)
}
draw_interact_target()
ctx.restore()
if (keys_down.has("KeyP")) {
camera_zoom = 0.05
ctx.fillStyle = "white"
ctx.textAlign = "left"
ctx.textBaseline = "bottom"
ctx.font = "20px sans-serif"
ctx.fillText(`interact = ${JSON.stringify(get_interact_target())}`, 10, 30)
} else { camera_zoom = 0.1 }
}
function draw_item(item: ItemData) {
ctx.save()
ctx.translate(item.x, item.y)
if (item.remove_anim) ctx.scale(1 - item.remove_anim, 1 - item.remove_anim)
const comps = ITEMS[data.item_names[item.kind]] ?? FALLBACK_ITEM
for (const c of comps) {
c(ctx)
}
if (item.progress !== null && item.progress !== undefined) {
ctx.fillStyle = "rgba(115, 230, 58, 0.66)"
ctx.fillRect(-0.5, -0.5, 1, item.progress)
}
ctx.restore()
}
function draw_interact_target() {
ctx.save()
ctx.translate(interact_target_anim.x, interact_target_anim.y)
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.lineWidth = 0.06 + 0.03 * Math.sin(Date.now() / 100)
ctx.strokeStyle = "rgb(84, 122, 236)"
ctx.strokeRect(0, 0, 1, 1)
ctx.restore()
}
function draw_grid() {
ctx.strokeStyle = "#333"
ctx.lineWidth = 0.01
ctx.beginPath()
const min = floor_v2(map_screen_to_world({ x: 0, y: 0 }))
const max = ceil_v2(map_screen_to_world({ x: canvas.width, y: canvas.height }))
for (let x = min.x; x < max.x; x++) {
ctx.moveTo(x, min.y)
ctx.lineTo(x, max.y)
}
for (let y = min.y; y < max.y; y++) {
ctx.moveTo(min.x, y)
ctx.lineTo(max.x, y)
}
ctx.stroke()
}
function collide_player(p: PlayerData) {
const tiles_ignored = ["floor", "door", "chair"].map(t => data.tile_names.indexOf(t))
for (const [_, tile] of tiles) {
if (tiles_ignored.includes(tile.kind)) continue
const d = aabb_circle_distance(tile.x, tile.y, tile.x + 1, tile.y + 1, p.x, p.y)
if (d > PLAYER_SIZE) continue
const h = 0.01
const d_sample_x = aabb_circle_distance(tile.x, tile.y, tile.x + 1, tile.y + 1, p.x + h, p.y)
const d_sample_y = aabb_circle_distance(tile.x, tile.y, tile.x + 1, tile.y + 1, p.x, p.y + h)
const grad_x = (d_sample_x - d) / h
const grad_y = (d_sample_y - d) / h
p.x += (PLAYER_SIZE - d) * grad_x
p.y += (PLAYER_SIZE - d) * grad_y
const vdotn = (grad_x * p.vel.x) + (grad_y * p.vel.y)
p.vel.x -= grad_x * vdotn
p.vel.y -= grad_y * vdotn
}
}