summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2024-06-19 22:52:37 +0200
committermetamuffin <metamuffin@disroot.org>2024-06-23 19:21:49 +0200
commit6ca76cc0568f3d60b280f11ae07a34303c317f34 (patch)
treeac867e47b3fb4b6ed99189cb302b2741b107d272
parent3dc8cc2abb4e6a7be8237b86dab6ebed75fa43cb (diff)
downloadhurrycurry-6ca76cc0568f3d60b280f11ae07a34303c317f34.tar
hurrycurry-6ca76cc0568f3d60b280f11ae07a34303c317f34.tar.bz2
hurrycurry-6ca76cc0568f3d60b280f11ae07a34303c317f34.tar.zst
implement customer communication
-rw-r--r--data/demands.yaml1
-rw-r--r--data/map.yaml4
-rw-r--r--data/recipes.yaml2
-rw-r--r--server/src/customer.rs8
-rw-r--r--server/src/game.rs27
-rw-r--r--server/src/protocol.rs14
-rw-r--r--test-client/main.ts187
-rw-r--r--test-client/protocol.ts7
-rw-r--r--test-client/tiles.ts37
-rw-r--r--test-client/visual.ts172
10 files changed, 287 insertions, 172 deletions
diff --git a/data/demands.yaml b/data/demands.yaml
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/data/demands.yaml
@@ -0,0 +1 @@
+
diff --git a/data/map.yaml b/data/map.yaml
index 4e9c2a11..aa065a47 100644
--- a/data/map.yaml
+++ b/data/map.yaml
@@ -3,7 +3,7 @@ map:
- "|ctc.ctc.ctc.ctc.ctc.|"
- "|.....c..............|"
- "|c...c...+--www---dd-+"
- - "|tc.ctc..|##...##W..D|"
+ - "|tc.ctc..|##...CC#..D|"
- "|c...c...w........~.S|"
- "|c.......w..######..T|"
- "|tc......w..........F|"
@@ -28,7 +28,7 @@ tiles:
"s": sink
"o": oven
"p": pan
- "W": watercooler
+ "C": cuttingboard
"S": raw-steak-crate
"T": tomato-crate
"F": flour-crate
diff --git a/data/recipes.yaml b/data/recipes.yaml
index d462acbe..46210a7e 100644
--- a/data/recipes.yaml
+++ b/data/recipes.yaml
@@ -21,7 +21,7 @@
- tile: tomato-crate # Tomato pipeline
outputs: [tomato]
action: !instant
-- tile: counter
+- tile: cuttingboard
inputs: [tomato]
outputs: [sliced-tomato]
action: !active 3
diff --git a/server/src/customer.rs b/server/src/customer.rs
index b003c935..87b67f0e 100644
--- a/server/src/customer.rs
+++ b/server/src/customer.rs
@@ -1,7 +1,7 @@
use crate::{
data::Gamedata,
game::Game,
- protocol::{PacketC, PacketS, PlayerID},
+ protocol::{Message, PacketC, PacketS, PlayerID},
};
use glam::{IVec2, Vec2};
use log::{debug, error};
@@ -118,6 +118,12 @@ impl DemandState {
packets_out
.push((p.id, move_player(p, &self.walkable, next - p.position, dt)));
} else {
+ packets_out.push((
+ p.id,
+ PacketS::Communicate {
+ message: Some(Message::Item(4)),
+ },
+ ));
p.state = CustomerState::Waiting { chair: *chair };
}
}
diff --git a/server/src/game.rs b/server/src/game.rs
index 228d7048..225455bf 100644
--- a/server/src/game.rs
+++ b/server/src/game.rs
@@ -1,7 +1,7 @@
use crate::{
data::Gamedata,
interaction::{interact, tick_tile, InteractEffect, TickEffect},
- protocol::{ItemIndex, PacketC, PacketS, PlayerID, RecipeIndex, TileIndex},
+ protocol::{ItemIndex, Message, PacketC, PacketS, PlayerID, RecipeIndex, TileIndex},
};
use anyhow::{anyhow, bail, Result};
use glam::{IVec2, Vec2};
@@ -36,6 +36,7 @@ pub struct Player {
pub position: Vec2,
pub interacting: Option<IVec2>,
pub item: Option<Item>,
+ pub communicate: Option<Message>,
}
pub struct Game {
@@ -80,12 +81,18 @@ impl Game {
character: player.character,
name: player.name.clone(),
item: player.item.as_ref().map(|i| i.kind),
- })
+ });
+ if let Some(c) = &player.communicate {
+ out.push(PacketC::Communicate {
+ player: id,
+ message: Some(c.to_owned()),
+ })
+ }
}
for (&tile, tdata) in &self.tiles {
out.push(PacketC::UpdateMap {
pos: tile,
- neighbours: [
+ neighbors: [
self.tiles.get(&(tile + IVec2::NEG_Y)).map(|e| e.kind),
self.tiles.get(&(tile + IVec2::NEG_X)).map(|e| e.kind),
self.tiles.get(&(tile + IVec2::Y)).map(|e| e.kind),
@@ -116,6 +123,7 @@ impl Game {
} else {
self.data.chef_spawn
},
+ communicate: None,
interacting: None,
name: name.clone(),
},
@@ -151,6 +159,11 @@ impl Game {
PacketS::Position { pos, rot } => {
self.packet_out
.push_back(PacketC::Position { player, pos, rot });
+ let player = self
+ .players
+ .get_mut(&player)
+ .ok_or(anyhow!("player does not exist"))?;
+ player.position = pos;
}
PacketS::Collide { player, force } => {
self.packet_out
@@ -224,6 +237,14 @@ impl Game {
player.interacting = if edge { Some(pos) } else { None };
}
+ PacketS::Communicate { message } => {
+ info!("{player} message {message:?}");
+ if let Some(player) = self.players.get_mut(&player) {
+ player.communicate = message.clone()
+ }
+ self.packet_out
+ .push_back(PacketC::Communicate { player, message })
+ }
}
Ok(())
}
diff --git a/server/src/protocol.rs b/server/src/protocol.rs
index 18b5f6fa..7eff2ba1 100644
--- a/server/src/protocol.rs
+++ b/server/src/protocol.rs
@@ -15,6 +15,14 @@ pub enum PacketS {
Position { pos: Vec2, rot: f32 },
Interact { pos: IVec2, edge: bool },
Collide { player: PlayerID, force: Vec2 },
+ Communicate { message: Option<Message> },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Message {
+ Text(String),
+ Item(ItemIndex),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -61,10 +69,14 @@ pub enum PacketC {
UpdateMap {
pos: IVec2,
tile: TileIndex,
- neighbours: [Option<TileIndex>; 4],
+ neighbors: [Option<TileIndex>; 4],
},
Collide {
player: PlayerID,
force: Vec2,
},
+ Communicate {
+ player: PlayerID,
+ message: Option<Message>,
+ },
}
diff --git a/test-client/main.ts b/test-client/main.ts
index 807567c8..b34220df 100644
--- a/test-client/main.ts
+++ b/test-client/main.ts
@@ -1,14 +1,13 @@
/// <reference lib="dom" />
-import { Gamedata, ItemIndex, PacketC, PacketS, PlayerID, TileIndex } from "./protocol.ts";
-import { FALLBACK_ITEM } from "./tiles.ts";
-import { FALLBACK_TILE, ITEMS, TILES } from "./tiles.ts";
-import { V2, add_v2, ceil_v2, floor_v2, length, lerp_exp_v2_mut, normalize, aabb_circle_distance } from "./util.ts";
+import { Gamedata, ItemIndex, Message, PacketC, PacketS, PlayerID, TileIndex } from "./protocol.ts";
+import { V2, add_v2, length, lerp_exp_v2_mut, normalize, aabb_circle_distance } from "./util.ts";
+import { draw_ingame, draw_wait } from "./visual.ts";
-const PLAYER_SIZE = 0.4;
+export const PLAYER_SIZE = 0.4;
-let ctx: CanvasRenderingContext2D;
-let canvas: HTMLCanvasElement;
+export let ctx: CanvasRenderingContext2D;
+export let canvas: HTMLCanvasElement;
let ws: WebSocket;
document.addEventListener("DOMContentLoaded", () => {
const ws_uri = window.location.protocol.endsWith("s:")
@@ -38,7 +37,7 @@ document.addEventListener("DOMContentLoaded", () => {
setInterval(tick_update, 1000 / 25);
})
-interface ItemData {
+export interface ItemData {
kind: ItemIndex,
x: number,
y: number,
@@ -46,7 +45,7 @@ interface ItemData {
progress?: number
remove_anim?: number
}
-interface PlayerData {
+export interface PlayerData {
x: number,
y: number,
name: string,
@@ -54,30 +53,31 @@ interface PlayerData {
item?: ItemData,
facing: V2,
character: number,
- vel: { x: number, y: number }
+ vel: { x: number, y: number },
+ message?: Message,
}
-interface TileData {
+export interface TileData {
x: number
y: number
kind: TileIndex
item?: ItemData
}
-const players = new Map<PlayerID, PlayerData>()
-const tiles = new Map<string, TileData>()
-const items_removed = new Set<ItemData>()
-let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0] }
+export const players = new Map<PlayerID, PlayerData>()
+export const tiles = new Map<string, TileData>()
+export const items_removed = new Set<ItemData>()
+
+export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0] }
+
let my_id: PlayerID = -1
-const camera: V2 = { x: 0, y: 0 }
-let camera_zoom = 0.1
-const interact_target_anim: V2 = { x: 0, y: 0 }
+export const camera: V2 = { x: 0, y: 0 }
+export const interact_target_anim: V2 = { x: 0, y: 0 }
let interacting: V2 | undefined;
-let scale = 0
function send(p: PacketS) { ws.send(JSON.stringify(p)) }
function packet(p: PacketC) {
- if (!["position", "set_active"].includes(p.type))
+ if (!["position", "set_active", "update_map"].includes(p.type))
console.log(p);
switch (p.type) {
case "init":
@@ -144,12 +144,17 @@ function packet(p: PacketC) {
case "update_map":
tiles.set(p.pos.toString(), { x: p.pos[0], y: p.pos[1], kind: p.tile })
break;
+ case "communicate": {
+ const player = players.get(p.player)!
+ player.message = p.message
+ break;
+ }
default:
console.warn("unknown packet", p);
}
}
-const keys_down = new Set();
+export const keys_down = new Set();
const HANDLED_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "Space"]
function keyboard(ev: KeyboardEvent, down: boolean) {
if (HANDLED_KEYS.includes(ev.code)) ev.preventDefault()
@@ -157,7 +162,7 @@ function keyboard(ev: KeyboardEvent, down: boolean) {
else keys_down.delete(ev.code)
}
-function get_interact_target(): V2 | undefined {
+export function get_interact_target(): V2 | undefined {
const me = players.get(my_id)
if (!me) return
return {
@@ -244,144 +249,6 @@ function draw() {
else throw new Error(`ws state invalid`);
requestAnimationFrame(draw)
}
-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)
-}
-
-
-function map_screen_to_world(screen: V2): V2 {
- return {
- x: ((screen.x - canvas.width / 2) / scale) + camera.x,
- y: ((screen.y - canvas.height / 2) / scale) + camera.y,
- }
-}
-
-function draw_ingame() {
- ctx.fillStyle = "#111"
- ctx.fillRect(0, 0, canvas.width, canvas.height)
-
- scale = Math.min(canvas.width, canvas.height) * camera_zoom;
- ctx.save()
- ctx.translate(canvas.width / 2, canvas.height / 2)
- ctx.scale(scale, scale)
- ctx.translate(-camera.x, -camera.y)
-
- draw_grid()
-
- for (const [_, tile] of tiles) {
- ctx.save()
- ctx.translate(tile.x + 0.5, tile.y + 0.5)
- const comps = TILES[data.tile_names[tile.kind]] ?? FALLBACK_TILE
- for (const c of comps) {
- c(ctx)
- }
- ctx.restore()
- }
-
- for (const [_, player] of players) {
- ctx.save()
- ctx.translate(player.x, player.y)
- ctx.rotate(-player.rot)
-
- ctx.fillStyle = `hsl(${player.character}rad, 50%, 50%)`
- ctx.beginPath()
- ctx.arc(0, 0, PLAYER_SIZE, 0, Math.PI * 2)
- ctx.fill()
-
- ctx.fillStyle = `hsl(${player.character}rad, 80%, 10%)`
- ctx.beginPath()
- ctx.arc(0, -0.2, PLAYER_SIZE, 0, Math.PI * 2)
- ctx.fill()
-
- ctx.fillStyle = `hsl(${player.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()
-
- ctx.restore()
-
- if (player.item) draw_item(player.item)
- }
-
- for (const item of items_removed) {
- draw_item(item)
- }
- for (const [_, tile] of tiles) {
- if (tile.item) draw_item(tile.item)
- }
-
- draw_interact_target()
-
- ctx.restore()
-
- if (keys_down.has("KeyP")) {
- camera_zoom = 0.05
- ctx.fillStyle = "white"
- ctx.textAlign = "left"
- ctx.textBaseline = "bottom"
- ctx.font = "20px sans-serif"
- ctx.fillText(`interact = ${JSON.stringify(get_interact_target())}`, 10, 30)
- } else { camera_zoom = 0.1 }
-}
-
-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)
- const comps = ITEMS[data.item_names[item.kind]] ?? FALLBACK_ITEM
- for (const c of comps) {
- c(ctx)
- }
- if (item.progress !== null && item.progress !== undefined) {
- ctx.fillStyle = "rgba(115, 230, 58, 0.66)"
- ctx.fillRect(-0.5, -0.5, 1, item.progress)
- }
- 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)
- ctx.strokeStyle = "rgb(84, 122, 236)"
- 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 collide_player(p: PlayerData) {
const tiles_ignored = ["floor", "door", "chair"].map(t => data.tile_names.indexOf(t))
diff --git a/test-client/protocol.ts b/test-client/protocol.ts
index aa3aa063..c20588ce 100644
--- a/test-client/protocol.ts
+++ b/test-client/protocol.ts
@@ -25,4 +25,9 @@ export type PacketC =
| { type: "produce_item", tile: Vec2, item: ItemIndex } // A tile generated a new item
| { type: "consume_item", tile: Vec2 } // A tile removed an item
| { type: "set_active", tile: Vec2, progress?: number } // A tile is doing something. progress goes from 0 to 1, then null when finished
- | { type: "update_map", pos: Vec2, tile: TileIndex, neighbours: [TileIndex | null] } // A map tile was changed
+ | { type: "update_map", pos: Vec2, tile: TileIndex, neighbors: [TileIndex | null] } // A map tile was changed
+ | { type: "communicate", player: PlayerID, message?: Message } // A map tile was changed
+
+export type Message =
+ { item: number }
+ | { text: string }
diff --git a/test-client/tiles.ts b/test-client/tiles.ts
index 5eee2b32..5a6769ed 100644
--- a/test-client/tiles.ts
+++ b/test-client/tiles.ts
@@ -7,8 +7,39 @@ function base(fill: string, stroke?: string, stroke_width?: number): Component {
c.lineWidth = stroke_width ?? 0.05
c.lineJoin = "miter"
c.lineCap = "square"
- c.fillRect(-0.5, -0.5, 1, 1)
- if (stroke) c.strokeRect(-0.5 + c.lineWidth / 2, -0.5 + c.lineWidth / 2, 1 - c.lineWidth, 1 - c.lineWidth)
+ c.fillRect(
+ -0.5,
+ -0.5,
+ 1,
+ 1
+ )
+ if (stroke) c.strokeRect(
+ -0.5 + c.lineWidth / 2,
+ -0.5 + c.lineWidth / 2,
+ 1 - c.lineWidth,
+ 1 - c.lineWidth
+ )
+ }
+}
+function rect(inset: number, fill: string, stroke?: string, stroke_width?: number): Component {
+ return c => {
+ c.fillStyle = fill;
+ c.strokeStyle = stroke ?? "black";
+ c.lineWidth = stroke_width ?? 0.05
+ c.lineJoin = "round"
+ c.lineCap = "round"
+ c.fillRect(
+ -0.5 + inset,
+ -0.5 + inset,
+ 1 - inset * 2,
+ 1 - inset * 2
+ )
+ if (stroke) c.strokeRect(
+ -0.5 + inset,
+ -0.5 + inset,
+ 1 - inset * 2,
+ 1 - inset * 2
+ )
}
}
function circle(radius: number, fill: string, stroke?: string, stroke_width?: number): Component {
@@ -93,7 +124,7 @@ export const TILES: { [key: string]: Component[] } = {
"chair": [...floor, circle(0.45, "rgb(136, 83, 41)")],
"wall": [base("rgb(0, 14, 56)")],
"window": [base("rgb(233, 233, 233)")],
- "watercooler": [...floor, circle(0.4, "rgb(64, 226, 207)")],
+ "cuttingboard": [...counter, rect(0.3, "rgb(158, 236, 68)", "rgb(158, 236, 68)", 0.2)],
"trash": [...floor, circle(0.4, "rgb(20, 20, 20)"), cross(0.3, "rgb(90, 36, 36)")],
"sink": [base("rgb(131, 129, 161)", "rgb(177, 174, 226)", 0.2)],
"oven": [base("rgb(241, 97, 61)", "rgb(109, 84, 84)", 0.3)],
diff --git a/test-client/visual.ts b/test-client/visual.ts
new file mode 100644
index 00000000..4bbfac6c
--- /dev/null
+++ b/test-client/visual.ts
@@ -0,0 +1,172 @@
+import { ItemData, PLAYER_SIZE, camera, canvas, ctx, data, get_interact_target, interact_target_anim, items_removed, keys_down, players, tiles } from "./main.ts";
+import { Message } from "./protocol.ts";
+import { FALLBACK_TILE, ITEMS, TILES, FALLBACK_ITEM } from "./tiles.ts";
+import { V2, ceil_v2, floor_v2 } from "./util.ts";
+
+let camera_zoom = 0.1
+let scale = 0
+
+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)
+
+ scale = Math.min(canvas.width, canvas.height) * camera_zoom;
+ ctx.save()
+ ctx.translate(canvas.width / 2, canvas.height / 2)
+ ctx.scale(scale, scale)
+ ctx.translate(-camera.x, -camera.y)
+
+ draw_grid()
+
+ for (const [_, tile] of tiles) {
+ ctx.save()
+ ctx.translate(tile.x + 0.5, tile.y + 0.5)
+ const comps = TILES[data.tile_names[tile.kind]] ?? FALLBACK_TILE
+ for (const c of comps) {
+ c(ctx)
+ }
+ ctx.restore()
+ }
+
+ for (const [_, player] of players) {
+ {
+ ctx.save()
+ ctx.translate(player.x, player.y)
+ {
+ ctx.save()
+ ctx.rotate(-player.rot)
+ draw_character(player.character)
+ ctx.restore()
+ }
+ if (player.message) draw_message(player.message)
+ ctx.restore()
+ }
+ if (player.item) draw_item(player.item)
+ }
+
+ for (const item of items_removed) {
+ draw_item(item)
+ }
+ for (const [_, tile] of tiles) {
+ if (tile.item) draw_item(tile.item)
+ }
+
+ draw_interact_target()
+
+ ctx.restore()
+
+ if (keys_down.has("KeyP")) {
+ camera_zoom = 0.05
+ ctx.fillStyle = "white"
+ ctx.textAlign = "left"
+ ctx.textBaseline = "bottom"
+ ctx.font = "20px sans-serif"
+ ctx.fillText(`interact = ${JSON.stringify(get_interact_target())}`, 10, 30)
+ } else { camera_zoom = 0.1 }
+}
+
+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)
+ const comps = ITEMS[data.item_names[item.kind]] ?? FALLBACK_ITEM
+ for (const c of comps) {
+ c(ctx)
+ }
+ if (item.progress !== null && item.progress !== undefined) {
+ ctx.fillStyle = "rgba(115, 230, 58, 0.66)"
+ ctx.fillRect(-0.5, -0.5, 1, item.progress)
+ }
+ 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)
+ ctx.strokeStyle = "rgb(84, 122, 236)"
+ 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(character: number) {
+ ctx.fillStyle = `hsl(${character}rad, 50%, 50%)`
+ ctx.beginPath()
+ ctx.arc(0, 0, PLAYER_SIZE, 0, Math.PI * 2)
+ ctx.fill()
+
+ ctx.fillStyle = `hsl(${character}rad, 80%, 10%)`
+ ctx.beginPath()
+ ctx.arc(0, -0.2, PLAYER_SIZE, 0, Math.PI * 2)
+ ctx.fill()
+
+ 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 draw_message(m: Message) {
+ ctx.save()
+ ctx.translate(0, -1)
+ if ("item" in m) {
+ ctx.fillStyle = "#fffa"
+ ctx.beginPath()
+ ctx.moveTo(0, 0.7)
+ ctx.arc(0, 0, 0.5, Math.PI / 4, Math.PI - Math.PI / 4, true)
+ ctx.closePath()
+ ctx.fill()
+
+ const comps = ITEMS[data.item_names[m.item]] ?? FALLBACK_ITEM
+ for (const c of comps) c(ctx)
+ }
+ ctx.restore()
+}
+
+function map_screen_to_world(screen: V2): V2 {
+ return {
+ x: ((screen.x - canvas.width / 2) / scale) + camera.x,
+ y: ((screen.y - canvas.height / 2) / scale) + camera.y,
+ }
+}
+