/* Undercooked - a game about cooking Copyright 2024 metamuffin This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ /// import { MovementBase, update_movement } from "./movement.ts"; import { Gamedata, ItemIndex, Message, PacketC, PacketS, PlayerID, TileIndex } from "./protocol.ts"; import { V2, add_v2, lerp_exp_v2_mut, normalize, lerp_exp, sub_v2, length } from "./util.ts"; import { draw_ingame, draw_wait } from "./visual.ts"; const KEY_INTERACT = "KeyJ" const KEY_BOOST = "KeyK" const KEY_UP = "KeyW" const KEY_DOWN = "KeyS" const KEY_LEFT = "KeyA" const KEY_RIGHT = "KeyD" const KEY_CHAT = "Enter" const KEY_CLOSE = "Escape" const HANDLED_KEYS = [KEY_BOOST, KEY_CHAT, KEY_CLOSE, KEY_DOWN, KEY_UP, KEY_LEFT, KEY_RIGHT] export let ctx: CanvasRenderingContext2D; export let canvas: HTMLCanvasElement; let ws: WebSocket; document.addEventListener("DOMContentLoaded", () => { const ws_uri = window.location.protocol.endsWith("s:") ? `wss://${window.location.host}/` : `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); }) export interface ItemData { kind: ItemIndex, x: number, y: number, tracking?: V2, progress?: number progress_warn?: boolean remove_anim?: number } export interface PlayerData extends MovementBase { name: string, item?: ItemData, character: number, anim_position: V2, message?: MessageData, } export interface TileData { x: number y: number kind: TileIndex item?: ItemData } export interface MessageData { inner: Message anim_position: V2, anim_size: number, } export const players = new Map() export const tiles = new Map() export const items_removed = new Set() export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0], tile_collide: [], tile_interact: [] } export let my_id: PlayerID = -1 export let demands_completed = 0 export let demands_failed = 0 export const camera: V2 = { x: 0, y: 0 } export let camera_scale = 0.05; export const interact_target_anim: V2 = { x: 0, y: 0 } export let interact_possible_anim: number = 0 export let interact_active_anim: number = 0 let interacting: V2 | undefined; function send(p: PacketS) { ws.send(JSON.stringify(p)) } function packet(p: PacketC) { if (!["position", "set_active", "update_map"].includes(p.type)) console.log(p); switch (p.type) { case "init": my_id = p.id break; case "data": data = p.data break; case "add_player": { players.set(p.id, { position: { x: p.position[0], y: p.position[1], }, anim_position: { x: p.position[0], y: p.position[1] }, character: p.character, name: p.name, rot: 0, facing: { x: 0, y: 1 }, vel: { x: 0, y: 0 }, stamina: 0, boosting: false, }) break; } case "remove_player": players.delete(p.id) break; case "position": { const pl = players.get(p.player)! const pos = { x: p.pos[0], y: p.pos[1] } // const dist = length(sub_v2(pl.position, pos));a // TODO this is actually not a good idea if latency is too high // if (p.player == my_id && dist < 3) return; // we know better where we are if (p.player == my_id) return pl.position.x = pos.x pl.position.y = pos.y 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.position 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 "set_tile_item": { const tile = tiles.get(p.tile.toString())! if (tile.item !== undefined && tile.item !== null) items_removed.add(tile.item) tile.item = undefined if (p.item !== undefined && p.item !== null) tile.item = { kind: p.item, x: p.tile[0] + 0.5, y: p.tile[1] + 0.5 } break; } case "set_player_item": { const player = players.get(p.player)! if (player.item !== undefined && player.item !== null) items_removed.add(player.item) player.item = undefined if (p.item !== undefined && p.item !== null) player.item = { kind: p.item, x: player.position.x + 0.5, y: player.position.y + 0.5 } break; } case "set_active": { const item = tiles.get(p.tile.toString())!.item!; item.progress = p.progress item.progress_warn = p.warn break; } case "update_map": if (p.kind !== undefined && p.kind !== null) tiles.set(p.tile.toString(), { x: p.tile[0], y: p.tile[1], kind: p.kind }) else tiles.delete(p.tile.toString()) break; case "communicate": { const player = players.get(p.player)! if (p.message) player.message = { inner: p.message, anim_size: 0., anim_position: player.anim_position } else player.message = undefined break; } case "score": demands_completed = p.demands_completed demands_failed = p.demands_failed break; case "error": console.warn(p.message) break; default: console.warn("unknown packet", p); } } export let chat: null | HTMLInputElement = null; export const keys_down = new Set(); function keyboard(ev: KeyboardEvent, down: boolean) { if (down && ev.code == KEY_CHAT) return toggle_chat() else if (down && ev.code == KEY_CLOSE && chat) return close_chat() else if (chat) return if (HANDLED_KEYS.includes(ev.code)) ev.preventDefault() if (!keys_down.has(KEY_INTERACT) && ev.code == KEY_INTERACT && down) set_interact(true) if (keys_down.has(KEY_INTERACT) && ev.code == KEY_INTERACT && !down) set_interact(false) if (down && ev.code == "Numpad1") send({ type: "communicate", message: { text: "/start default-small-default" } }) if (down && ev.code == "Numpad2") send({ type: "communicate", message: { text: "/start default-big-default" } }) if (down && ev.code == "Numpad0") send({ type: "communicate", message: { text: "/end" } }) if (down) keys_down.add(ev.code) else keys_down.delete(ev.code) } function close_chat() { if (!chat) return chat.remove() canvas.focus() chat = null; } function toggle_chat() { if (chat) { if (chat.value.length) send({ type: "communicate", message: { text: chat.value } }) else send({ type: "communicate" }) chat.remove() canvas.focus() chat = null; } else { chat = document.createElement("input") chat.type = "text" chat.placeholder = "Message" document.body.append(chat) chat.focus() } } export function get_interact_target(): V2 | undefined { if (interacting) return interacting const me = players.get(my_id) if (!me) return return { x: Math.floor(me.position.x + Math.sin(me.rot)), y: Math.floor(me.position.y + Math.cos(me.rot)) } } function set_interact(edge: boolean) { if (edge) interacting = get_interact_target() if (interacting) send({ type: "interact", pos: [interacting.x, interacting.y], edge }) if (!edge) interacting = undefined } function tick_update() { const p = players.get(my_id) if (!p) return send({ type: "position", pos: [p.position.x, p.position.y], rot: p.rot }) } function frame_update(dt: number) { const p = players.get(my_id) if (!p) return const input = normalize({ x: (+keys_down.has(KEY_RIGHT) - +keys_down.has(KEY_LEFT)), y: (+keys_down.has(KEY_DOWN) - +keys_down.has(KEY_UP)) }) if (interacting) input.x *= 0, input.y *= 0 update_movement(p, dt, input, keys_down.has("KeyK")) const update_item = (item: ItemData) => { if (item.tracking) lerp_exp_v2_mut(item, item.tracking, dt * 10.) } for (const [pid, player] of players) { if (pid == my_id) player.anim_position.x = player.position.x, player.anim_position.y = player.position.y else lerp_exp_v2_mut(player.anim_position, player.position, dt * 15) if (player.item !== undefined && player.item !== null) update_item(player.item) if (player.message) player.message.anim_size = lerp_exp(player.message.anim_size, 1, dt * 3) } for (const [_, tile] of tiles) { if (tile.item !== undefined && tile.item !== null) 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(camera, p.position, dt * 10.) const it = get_interact_target() ?? { x: 0, y: 0 }; const possible = data.tile_interact[tiles.get([it.x, it.y].toString())?.kind ?? 0] ?? false lerp_exp_v2_mut(interact_target_anim, it, dt * 15.) interact_possible_anim = lerp_exp(interact_possible_anim, +possible, dt * 18.) interact_active_anim = lerp_exp(interact_active_anim, +!!interacting, dt * 15.) const zoom_target = Math.min(canvas.width, canvas.height) * (keys_down.has("KeyL") ? 0.05 : 0.1) camera_scale = lerp_exp(camera_scale, zoom_target, dt * 5) } 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) }