/// 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 } }