/* 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 { tr } from "./locale.ts"; import { ItemData, MessageData, MessageStyle, PlayerData, TileData, camera, camera_scale, canvas, ctx, data, get_interact_target, global_message, interact_active_anim, interact_possible_anim, interact_target_anim, is_lobby, items_removed, keys_down, my_id, overlay_vis_anim, players, score, server_hints, tiles } from "./main.ts"; import { PLAYER_SIZE } from "./movement.ts"; import { draw_item_sprite, draw_tile_sprite, ItemName, TileName } from "./tiles.ts"; import { V2, ceil_v2, floor_v2 } from "./util.ts"; import { Message, PlayerClass } from "./protocol.ts"; import { draw_particles, particle_count } from "./particles.ts"; export 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) } export function draw_ingame() { ctx.fillStyle = "#111" ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.save() ctx.translate(canvas.width / 2, canvas.height / 2) ctx.scale(camera_scale, camera_scale) ctx.translate(-camera.x, -camera.y) draw_grid() for (const [_, tile] of tiles) draw_tile(tile) for (const [_, player] of players) draw_player(player) for (const item of items_removed) draw_item(item) for (const [_, tile] of tiles) if (tile.item) draw_item(tile.item) draw_particles() draw_interact_target() for (const [_, player] of players) if (player.message) draw_message(player.message, true) for (const [_, player] of players) if (player.message_persist) draw_message(player.message_persist, true) for (const [_, player] of players) draw_player_nametag(player) for (const [_, message] of server_hints) draw_message(message, true) ctx.restore() ctx.save() ctx.translate(50, 50) ctx.scale(80, 80) for (const [_, player] of players) if (player.message_pinned) draw_message(player.message_pinned, false) ctx.restore() draw_global_message() if (!is_lobby) draw_score() if (keys_down.has("KeyP")) draw_debug() } function draw_score() { ctx.fillStyle = "white" ctx.textAlign = "left" ctx.textBaseline = "bottom" ctx.font = "20px sans-serif" ctx.fillText(`${tr("c.score.time_remaining")}: ${score.time_remaining?.toFixed(2)}`, 10, canvas.height - 90) ctx.font = "30px sans-serif" ctx.fillText(`${tr("c.score.points")}: ${score.points}`, 10, canvas.height - 60) ctx.font = "20px sans-serif" ctx.fillText(`${tr("c.score.completed")}: ${score.demands_completed}`, 10, canvas.height - 30) ctx.fillText(`${tr("c.score.failed")}: ${score.demands_failed}`, 10, canvas.height - 10) } function draw_debug() { ctx.fillStyle = "white" ctx.textAlign = "left" ctx.textBaseline = "bottom" ctx.font = "20px sans-serif" ctx.fillText(`position = ${JSON.stringify(players.get(my_id)?.anim_position)}`, 10, 30) ctx.fillText(`velocity = ${JSON.stringify(players.get(my_id)?.vel)}`, 10, 50) ctx.fillText(`interact = ${JSON.stringify(get_interact_target())}`, 10, 70) ctx.fillText(`particle_count = ${particle_count()}`, 10, 90) } function draw_tile(tile: TileData) { ctx.save() ctx.translate(tile.x + 0.5, tile.y + 0.5) draw_tile_sprite(ctx, data.tile_names[tile.kind] as TileName) ctx.restore() } 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) draw_item_sprite(ctx, data.item_names[item.kind] as ItemName) if (item.active) { ctx.fillStyle = item.active.warn ? "rgba(230, 58, 58, 0.66)" : "rgba(115, 230, 58, 0.66)" ctx.fillRect(-0.5, -0.5, 1, item.active.position) } ctx.restore() } function draw_player(player: PlayerData) { ctx.save() ctx.translate(player.anim_position.x, player.anim_position.y) ctx.rotate(-player.rot) if (player.boosting) ctx.scale(1.3, 1.3) draw_character(player.class, player.character) ctx.restore() ctx.save() for (const h of player.hands) { if (h.item) draw_item(h.item) ctx.translate(0.2, 0.0) } ctx.restore() } function draw_player_nametag(player: PlayerData) { if (overlay_vis_anim > 0.01) { ctx.save() ctx.translate(player.anim_position.x, player.anim_position.y) ctx.translate(0, -1) ctx.textAlign = "center" ctx.font = "15px sans-serif" ctx.scale(overlay_vis_anim / camera_scale, overlay_vis_anim / camera_scale) const w = ctx.measureText(player.name).width + 20 ctx.fillStyle = "#fffa" ctx.beginPath() ctx.roundRect(-w / 2, -10, w, 20, 5) ctx.fill() ctx.fillStyle = "black" ctx.textBaseline = "middle" ctx.fillText(player.name, 0, 0) 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) * interact_possible_anim ctx.strokeStyle = `hsla(${(1 - interact_active_anim) * 225 + interact_active_anim * 125}deg, ${interact_possible_anim * 100}%, 62.70%, ${interact_possible_anim * 0.7 + 0.3})` 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 draw_character(pclass: PlayerClass, character: number) { ctx.fillStyle = `hsl(${character}rad, 50%, 50%)` ctx.beginPath() ctx.arc(0, 0, PLAYER_SIZE, 0, Math.PI * 2) ctx.fill() if (pclass != "customer") { ctx.fillStyle = `hsl(${character}rad, 80%, 10%)` ctx.beginPath() ctx.arc(0, -0.2, PLAYER_SIZE, 0, Math.PI * 2) ctx.fill() } if (pclass != "bot") { ctx.fillStyle = `hsl(${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() } function message_str(m: Message): string { if ("text" in m) return m.text if ("translation" in m) return tr(m.translation.id, ...m.translation.params.map(message_str)) if ("tile" in m) return data.tile_names[m.tile] if ("item" in m) return data.item_names[m.item] return "[unknown message type]" } const MESSAGE_BG: { [key in MessageStyle]: string } = { normal: "#fff", hint: "#111", error: "#fff", pinned: "rgb(4, 32, 0)" } const MESSAGE_FG: { [key in MessageStyle]: string } = { normal: "#000", hint: "#fff", error: "#a00", pinned: "#000" } function draw_message(m: MessageData, world: boolean) { ctx.save() ctx.translate(m.anim_position.x, m.anim_position.y) const scale = Math.min(m.anim_size, 1 - overlay_vis_anim); ctx.scale(scale, scale) if ("item" in m.inner) { ctx.fillStyle = MESSAGE_BG[m.style] if (m.style == "pinned") { ctx.translate(0, 1) ctx.beginPath() ctx.arc(0, -1, 0.5, 0, Math.PI * 2) ctx.fill() } else { ctx.beginPath() ctx.moveTo(0, -0.3) ctx.arc(0, -1, 0.5, Math.PI / 4, Math.PI - Math.PI / 4, true) ctx.closePath() ctx.fill() } if (m.timeout) { const t = m.timeout.remaining / m.timeout.initial; ctx.beginPath() ctx.strokeStyle = `hsl(${Math.sqrt(t) * 0.3}turn, 100%, 50%)` ctx.lineWidth = 0.1 ctx.arc(0, -1, 0.45, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (1 - t)) ctx.stroke() } ctx.translate(0, -1) draw_item_sprite(ctx, data.item_names[m.inner.item] as ItemName) ctx.translate(0, 1) } if ("text" in m.inner || "translation" in m.inner) { ctx.translate(0, -1) ctx.textAlign = "center" ctx.font = "15px " + (world ? "sans-serif" : "monospace") if (world) ctx.scale(2 / camera_scale, 2 / camera_scale) const lines = message_str(m.inner).split("\n") const w = lines.reduce((a, v) => Math.max(a, ctx.measureText(v).width), 0) + 10 if (world) ctx.translate(0, -lines.length * 15 / 2) ctx.fillStyle = MESSAGE_BG[m.style] ctx.beginPath() ctx.roundRect(-w / 2, -5, w, lines.length * 15 + 10, 5) ctx.fill() ctx.fillStyle = MESSAGE_FG[m.style] ctx.textAlign = "left" ctx.textBaseline = "top" for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], -w / 2 + 5, i * 15) ctx.translate(0, 1) } ctx.restore() } function draw_global_message() { if (!global_message) return ctx.save() ctx.translate(canvas.width / 2, canvas.height / 6) ctx.scale(2, 2) draw_message(global_message, false) ctx.restore() } function map_screen_to_world(screen: V2): V2 { return { x: ((screen.x - canvas.width / 2) / camera_scale) + camera.x, y: ((screen.y - canvas.height / 2) / camera_scale) + camera.y, } }