/* Hurry Curry! - 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 { init_locale } from "./locale.ts"; import { MovementBase, collide_player_player, update_movement } from "./movement.ts"; import { particle_splash, tick_particles } from "./particles.ts"; import { Gamedata, ItemIndex, ItemLocation, Message, MessageTimeout, PacketC, PacketS, PlayerID, Score, TileIndex } from "./protocol.ts"; import { V2, lerp_exp_v2_mut, normalize, lerp_exp } 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_INTERACT, 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", async () => { await init_locale() 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()) const tick_int = setInterval(tick_update, 1000 / 25); ws.addEventListener("close", () => clearInterval(tick_int)) }) export interface ItemData { kind: ItemIndex, x: number, y: number, tracking?: V2, active?: Involvement remove_anim?: number } export interface Involvement { position: number, speed: number, warn: boolean } export interface PlayerData extends MovementBase { id: number, name: string, item?: ItemData, direction: V2, character: number, anim_position: V2, message_persist?: MessageData, message?: MessageData, } export interface TileData { x: number, y: number // tile position position: V2, // center position kind: TileIndex item?: ItemData } export interface MessageData { inner: Message anim_position: V2, anim_size: number, timeout?: MessageTimeout, } export const players = new Map() export const tiles = new Map() export const items_removed = new Set() export const server_hints = new Map() export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0], tile_collide: [], tile_interact: [], maps: [] } export let global_message: MessageData | undefined = undefined export let my_id: PlayerID = -1 export const score: Score = { active_recipes: 0, demands_completed: 0, demands_failed: 0, instant_recipes: 0, passive_recipes: 0, players: 0, points: 0, stars: 0, time_remaining: 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 export let nametag_scale_anim: number = 0 export let is_lobby = false let interacting: V2 | undefined; let last_server_sent_position: V2 = { x: 0, y: 0 } function get_item_location(loc: ItemLocation): PlayerData | TileData { if ("tile" in loc) return tiles.get(loc.tile.toString())! if ("player" in loc) return players.get(loc.player)! throw new Error("invalid item location"); } function send(p: PacketS) { ws.send(JSON.stringify(p)) } function packet(p: PacketC) { if (!["movement", "update_map"].includes(p.type)) console.log(p); switch (p.type) { case "version": console.log(`Protocol version: ${p.major}.${p.minor}`); break; case "joined": my_id = p.id break; case "data": data = p.data break; case "add_player": { players.set(p.id, { id: 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 }, direction: { x: 0, y: 0 }, stamina: 0, boosting: false, }) break; } case "remove_player": players.delete(p.id) break; case "movement": { 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 last_server_sent_position = pos pl.position.x = pos.x pl.position.y = pos.y pl.boosting = p.boost pl.rot = p.rot break; } case "move_item": { const from = get_item_location(p.from) const to = get_item_location(p.to) to.item = from.item to.item!.tracking = to.position from.item = undefined break; } case "set_item": { const slot = get_item_location(p.location) if (slot.item !== undefined && slot.item !== null) items_removed.add(slot.item) slot.item = undefined if (p.item !== undefined && p.item !== null) slot.item = { kind: p.item, x: slot.position.x, y: slot.position.y, tracking: slot.position } break; } case "clear_progress": { delete get_item_location(p.item).item!.active break; } case "set_progress": { const slot = get_item_location(p.item) if (!slot.item) return slot.item.active = { position: p.position, speed: p.speed, 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], position: { x: p.tile[0] + 0.5, y: p.tile[1] + 0.5 }, kind: p.kind }) else tiles.delete(p.tile.toString()) break; case "communicate": { const player = players.get(p.player)! if (p.message) { const message = { inner: p.message, anim_size: 0., anim_position: player.anim_position, timeout: p.timeout ?? { initial: 5, remaining: 5 } }; if (p.timeout === undefined) player.message = message else player.message_persist = message } else if (p.timeout !== undefined) { delete player.message_persist } break; } case "score": score.demands_completed = p.demands_completed score.demands_failed = p.demands_failed score.points = p.points score.time_remaining = p.time_remaining ?? null break; case "server_message": // TODO error -> red global_message = { inner: p.message, anim_size: 0., anim_position: { x: 0, y: 0 }, timeout: { initial: 5, remaining: 5 } } break; case "set_ingame": console.log(`ingame ${p.state}`); is_lobby = p.lobby break; case "movement_sync": { const me = players.get(my_id) if (me) me.position = last_server_sent_position break; } case "server_hint": if (p.message) server_hints.set(p.position + "", { inner: p.message, anim_size: 0., anim_position: p.position ? { x: p.position[0] + 0.5, y: p.position[1] + 0.5 } : players.get(my_id)!.anim_position }) else server_hints.delete(p.position + "") break; case "environment": break case "effect": break; case "menu": switch (p.menu) { case "book": open("https://s.metamuffin.org/static/hurrycurry/book.pdf"); break case "score": global_message = { timeout: { initial: 5, remaining: 5 }, inner: { text: `Score: ${JSON.stringify(p.data, null, 4)}` }, anim_position: { x: 0, y: 0 }, anim_size: 0 }; break default: console.warn("unknown menu"); } 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({ player: my_id, type: "communicate", message: { text: "/start junior" } }) if (down && ev.code == "Numpad2") send({ player: my_id, type: "communicate", message: { text: "/start senior" } }) if (down && ev.code == "Numpad3") send({ player: my_id, type: "communicate", message: { text: "/start sophomore" } }) if (down && ev.code == "Numpad4") send({ player: my_id, type: "communicate", message: { text: "/start debug" } }) if (down && ev.code == "Numpad5") send({ player: my_id, type: "communicate", message: { text: "/start 5star" } }) if (down && ev.code == "Numpad8") send({ player: my_id, type: "communicate", message: { text: "/start-tutorial plate:seared-patty,sliced-bun" } }) if (down && ev.code == "Numpad9") send({ player: my_id, type: "communicate", message: { text: "/start-tutorial plate:bun" } }) if (down && ev.code == "Numpad0") send({ player: my_id, type: "communicate", message: { text: "/end" } }) if (down && ev.code == "KeyE") particle_splash(get_interact_target() ?? { x: 0, y: 0 }) 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({ player: my_id, type: "communicate", message: { text: chat.value } }) 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({ player: my_id, type: "interact", pos: edge ? [interacting.x, interacting.y] : undefined }) if (!edge) interacting = undefined } function tick_update() { const p = players.get(my_id) if (!p) return send({ player: my_id, type: "movement", pos: [p.position.x, p.position.y], dir: [p.direction.x, p.direction.y], boost: p.boosting }) } function frame_update(dt: number) { const p = players.get(my_id) if (!p) return score.time_remaining -= dt const direction = 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) direction.x *= 0, direction.y *= 0 p.direction = direction update_movement(p, dt, direction, keys_down.has("KeyK")) for (const [_, a] of players) for (const [_, b] of players) collide_player_player(a, b, dt) const update_item = (item: ItemData) => { if (item.tracking) lerp_exp_v2_mut(item, item.tracking, dt * 10.) if (item.active) item.active.position += item.active.speed * dt } 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 && tick_message(player.message, dt)) delete player.message if (player.message_persist && tick_message(player.message_persist, dt)) delete player.message_persist } for (const [_, tile] of tiles) { if (tile.item !== undefined && tile.item !== null) update_item(tile.item) } const remove: ItemData[] = [] 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)) for (const [_, h] of server_hints) tick_message(h, dt); lerp_exp_v2_mut(camera, p.position, dt * 10.) if (global_message && tick_message(global_message, dt)) global_message = undefined 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) nametag_scale_anim = lerp_exp(nametag_scale_anim, +keys_down.has("KeyL"), dt * 10) tick_particles(dt) } 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 tick_message(m: MessageData | undefined, dt: number): boolean { if (!m) return true m.anim_size = lerp_exp(m.anim_size, m.timeout ? m.timeout.remaining > 0.3 ? 1 : 0 : 1, dt * 3) if (!m.timeout) return false m.timeout.remaining -= dt; return m.timeout.remaining <= 0 }