/*
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_pinned?: MessageData,
message?: MessageData,
}
export interface TileData {
x: number, y: number // tile position
position: V2, // center position
kind: TileIndex
item?: ItemData
}
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: [] }
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): 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: 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 "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, 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 senior",
"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",
}
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 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 })
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
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]) {
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) {
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
}
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)
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
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("KeyL") ? 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("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
}