/* Hurry Curry! - a game about cooking Copyright (C) 2025 Hurry Curry! Contributors 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, PlayerClass, 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_LEFT = "KeyJ" const KEY_INTERACT_RIGHT = "KeyL" 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 KEY_ZOOM = "KeyI" const HANDLED_KEYS = [KEY_INTERACT_LEFT, 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 = globalThis.location.protocol.endsWith("s:") ? `wss://${globalThis.location.host}/` : `ws://${globalThis.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: { color: Math.floor(Math.random() * 100), hairstyle: 0, headwear: 0 }, class: "chef" }) } 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 ItemSlot { item?: ItemData, } export interface PlayerData extends MovementBase { id: number, name: string, hands: ItemSlot[], direction: V2, class: PlayerClass, character: number, anim_position: V2, message_persist?: MessageData, message_pinned?: MessageData, message?: MessageData, } export interface TileData extends ItemSlot { x: number, y: number // tile position position: V2, // center position kind: TileIndex } export type MessageStyle = "hint" | "normal" | "error" | "pinned" export interface MessageData { style: MessageStyle 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: [], hand_count: 0 } 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 overlay_vis_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): ItemSlot { if ("tile" in loc) return tiles.get(loc.tile.toString())! if ("player" in loc) return players.get(loc.player[0])!.hands[loc.player[1]] throw new Error("invalid item location"); } function get_item_location_tracking(loc: ItemLocation): V2 { if ("tile" in loc) return tiles.get(loc.tile.toString())!.position if ("player" in loc) return players.get(loc.player[0])!.position throw new Error("invalid item location"); } function send(p: PacketS) { if (p.type != "movement") console.log("send", p); 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, hands: new Array(data.hand_count).fill(null).map(() => ({})), position: { x: p.position[0], y: p.position[1], }, anim_position: { x: p.position[0], y: p.position[1] }, character: p.character.color, class: p.class, 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 = get_item_location_tracking(p.to) from.item = undefined break; } case "set_item": { const slot = get_item_location(p.location) const slotpos = get_item_location_tracking(p.location) if (slot.item !== undefined && slot.item !== null) items_removed.add(slot.item) delete slot.item if (p.item !== undefined && p.item !== null) slot.item = { kind: p.item, x: slotpos.x, y: slotpos.y, tracking: slotpos } 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: player.message_persist?.anim_size ?? 0, anim_position: player.anim_position, timeout: p.timeout ?? { initial: 5, remaining: 5, pinned: false }, style: "normal" as const }; if (p.timeout) player.message_persist = message else player.message = message if (p.timeout?.pinned) player.message_pinned = { inner: p.message, anim_size: player.message_pinned?.anim_size ?? 0, anim_position: { x: 20, y: 0 }, style: "pinned", timeout: p.timeout } } else { delete player.message_persist delete player.message_pinned } 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": global_message = { inner: p.message, style: p.error ? "error" : "normal", anim_size: 0., anim_position: { x: 0, y: 0 }, timeout: { initial: 5, remaining: 5, pinned: false } } 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.x = last_server_sent_position.x // ensuring object has same ref me.position.y = last_server_sent_position.y } break; } case "server_hint": if (p.player != my_id) return; 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, style: "hint" }) else server_hints.delete(p.position + "") break; case "tutorial_ended": break; case "environment": break case "effect": break; case "flush_map": break; case "menu": switch (p.menu) { case "document": // TODO implement document format if (JSON.stringify(p.data).search("b.toc") != -1) open("https://s.metamuffin.org/static/hurrycurry/book.pdf"); else alert("document display not supported yet") break case "score": global_message = { timeout: { initial: 5, remaining: 5, pinned: false }, inner: { text: `Score: ${JSON.stringify(p.data, null, 4)}` }, anim_position: { x: 0, y: 0 }, anim_size: 0, style: "normal" }; break default: console.warn("unknown menu"); } break; default: console.warn("unknown packet", p); } } export let chat: null | HTMLInputElement = null; const QUICK_COMMANDS: { [key: string]: string } = { "Numpad1": "/start junior", "Numpad2": "/start debug2", "Numpad3": "/start sophomore", "Numpad4": "/start debug", "Numpad5": "/start 5star", "Numpad6": "/start campaign/lobby", "Numpad8": "/start-tutorial plate:seared-patty,sliced-bun", "Numpad9": "/start-tutorial plate:bun", "Numpad7": "/end-tutorial", "Numpad0": "/end", "NumpadEnter": "/r", } 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_LEFT) && ev.code == KEY_INTERACT_LEFT && down) set_interact(true, 0) if (keys_down.has(KEY_INTERACT_LEFT) && ev.code == KEY_INTERACT_LEFT && !down) set_interact(false, 0) if (!keys_down.has(KEY_INTERACT_RIGHT) && ev.code == KEY_INTERACT_RIGHT && down) set_interact(true, 1) if (keys_down.has(KEY_INTERACT_RIGHT) && ev.code == KEY_INTERACT_RIGHT && !down) set_interact(false, 1) if (down && ev.code in QUICK_COMMANDS) send({ player: my_id, type: "communicate", message: { text: QUICK_COMMANDS[ev.code] } }) if (down && ev.code == "KeyE") particle_splash(get_interact_target() ?? { x: 0, y: 0 }, 0.8) 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 if (location.hash.search("newinteract") == -1) return { x: Math.floor(me.position.x + Math.sin(me.rot)), y: Math.floor(me.position.y + Math.cos(me.rot)) } const rx = me.position.x + Math.sin(me.rot) * 0.7 const ry = me.position.y + Math.cos(me.rot) * 0.7 const bx = Math.floor(rx) const by = Math.floor(ry) let best = { x: bx, y: by } let best_d = 100 for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) { const t = tiles.get([bx + ox, by + oy] + "") if (!t) continue if (data.tile_interact[t.kind] || t.item) { const cx = (bx + ox + 0.5) const cy = (by + oy + 0.5) const dx = rx - cx const dy = ry - cy const pdx = me.position.x - cx const pdy = me.position.y - cy const d = Math.sqrt(dx * dx + dy * dy) const pd = Math.sqrt(pdx * pdx + pdy * pdy) if (pd < 2 && d < best_d) { best_d = d best = { x: bx + ox, y: by + oy } } } } return best } function set_interact(edge: boolean, hand: number) { if (edge) interacting = get_interact_target() if (interacting) send({ player: my_id, type: "interact", pos: edge ? [interacting.x, interacting.y] : undefined, hand }) 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 } let pin_xo = 0 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) player.hands.forEach(h => { if (h.item) update_item(h.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 if (player.message_pinned && tick_message(player.message_pinned, dt)) delete player.message_pinned if (player.message_pinned) lerp_exp_v2_mut(player.message_pinned.anim_position, { x: pin_xo++, y: 0 }, dt * 5) } 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(KEY_ZOOM) ? 0.05 : 0.1) camera_scale = lerp_exp(camera_scale, zoom_target, dt * 5) overlay_vis_anim = lerp_exp(overlay_vis_anim, +keys_down.has(KEY_ZOOM), 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 }