diff options
author | metamuffin <metamuffin@disroot.org> | 2024-09-17 16:57:23 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2024-09-17 16:57:34 +0200 |
commit | 7cacd111e2b443bac291244b168c20e2c8bf69ec (patch) | |
tree | 277ace5a4e8223df4b156e766c2660406101a82b | |
parent | 77a73b415888b0285ba64b27c3f69440216e475c (diff) | |
download | hurrycurry-7cacd111e2b443bac291244b168c20e2c8bf69ec.tar hurrycurry-7cacd111e2b443bac291244b168c20e2c8bf69ec.tar.bz2 hurrycurry-7cacd111e2b443bac291244b168c20e2c8bf69ec.tar.zst |
add hint-based tutorial for item crafting
-rw-r--r-- | server/protocol/src/lib.rs | 2 | ||||
-rw-r--r-- | server/src/commands.rs | 30 | ||||
-rw-r--r-- | server/src/entity/bot.rs | 5 | ||||
-rw-r--r-- | server/src/entity/customers.rs | 2 | ||||
-rw-r--r-- | server/src/entity/mod.rs | 3 | ||||
-rw-r--r-- | server/src/entity/tutorial.rs | 218 | ||||
-rw-r--r-- | server/src/server.rs | 2 | ||||
-rw-r--r-- | test-client/main.ts | 17 | ||||
-rw-r--r-- | test-client/protocol.ts | 4 | ||||
-rw-r--r-- | test-client/visual.ts | 21 |
10 files changed, 282 insertions, 22 deletions
diff --git a/server/protocol/src/lib.rs b/server/protocol/src/lib.rs index 54a7f868..706747f4 100644 --- a/server/protocol/src/lib.rs +++ b/server/protocol/src/lib.rs @@ -136,7 +136,7 @@ pub enum PacketS { }, } -#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Message { Text(String), diff --git a/server/src/commands.rs b/server/src/commands.rs index 96d9ab75..904305bf 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -15,7 +15,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -use crate::{entity::bot::BotDriver, server::Server}; +use crate::{ + entity::{bot::BotDriver, tutorial::Tutorial}, + server::Server, +}; use anyhow::{anyhow, bail, Result}; use clap::{Parser, ValueEnum}; use hurrycurry_bot::algos::ALGO_CONSTRUCTORS; @@ -58,18 +61,28 @@ enum Command { /// List all recipes and maps List, /// Send an effect - Effect { name: String }, + Effect { + name: String, + }, /// Send an item - Item { name: String }, + Item { + name: String, + }, /// Reload the resource index ReloadIndex, #[clap(alias = "summon-bot", alias = "spawn-bot")] - CreateBot { algo: String, name: Option<String> }, + CreateBot { + algo: String, + name: Option<String>, + }, /// Reload the current map #[clap(alias = "r")] Reload, /// Shows the recipe book Book, + StartTutorial { + item: String, + }, } #[derive(ValueEnum, Clone)] @@ -217,6 +230,15 @@ impl Server { info.players ) } + Command::StartTutorial { item } => { + let item = self + .game + .data + .get_item_by_name(&item) + .ok_or(anyhow!("unknown item"))?; + // TODO prevent too many (> 1) tutorials for this player + self.entities.push(Box::new(Tutorial::new(player, item))); + } } Ok(()) } diff --git a/server/src/entity/bot.rs b/server/src/entity/bot.rs index 79ca97f1..6f39fab2 100644 --- a/server/src/entity/bot.rs +++ b/server/src/entity/bot.rs @@ -29,7 +29,7 @@ pub struct BotDriver<T> { join_data: Option<(String, i32)>, id: PlayerID, interacting: bool, - pub left: bool, + left: bool, } impl<T: BotAlgo> BotDriver<T> { @@ -44,6 +44,9 @@ impl<T: BotAlgo> BotDriver<T> { } } impl<T: BotAlgo> Entity for BotDriver<T> { + fn finished(&self) -> bool { + self.left + } fn tick(&mut self, c: EntityContext<'_>) -> Result<()> { if let Some((name, character)) = self.join_data.take() { self.id = PlayerID(random()); // TODO bad code, can collide diff --git a/server/src/entity/customers.rs b/server/src/entity/customers.rs index da905bc0..8c515e92 100644 --- a/server/src/entity/customers.rs +++ b/server/src/entity/customers.rs @@ -58,7 +58,7 @@ impl Entity for Customers { load_map: c.load_map, })?; } - self.customers.retain(|c| !c.left); + self.customers.retain(|c| !c.finished()); Ok(()) } } diff --git a/server/src/entity/mod.rs b/server/src/entity/mod.rs index 70c5c785..f87dbb32 100644 --- a/server/src/entity/mod.rs +++ b/server/src/entity/mod.rs @@ -59,6 +59,9 @@ pub trait Entity { fn tick(&mut self, _c: EntityContext<'_>) -> Result<()> { Ok(()) } + fn finished(&self) -> bool { + false + } fn destructor(&mut self, _c: EntityContext<'_>) {} fn interact( &mut self, diff --git a/server/src/entity/tutorial.rs b/server/src/entity/tutorial.rs index 20b1d03e..24c83550 100644 --- a/server/src/entity/tutorial.rs +++ b/server/src/entity/tutorial.rs @@ -1,16 +1,68 @@ use super::{Entity, EntityContext}; use anyhow::Result; -use hurrycurry_protocol::{glam::IVec2, PlayerID}; +use hurrycurry_protocol::{ + glam::IVec2, ItemIndex, Message, PacketC, PlayerID, Recipe, RecipeIndex, TileIndex, +}; +use log::{debug, warn}; pub struct Tutorial { player: PlayerID, + target: ItemIndex, + + current_hint: Option<(Option<IVec2>, Message)>, + delete_timer: f32, +} + +impl Tutorial { + pub fn new(player: PlayerID, item: ItemIndex) -> Self { + Self { + player, + target: item, + current_hint: None, + delete_timer: 1.5, + } + } } impl Entity for Tutorial { - fn tick(&mut self, _c: EntityContext<'_>) -> Result<()> { + fn finished(&self) -> bool { + self.delete_timer <= 0. + } + fn tick(&mut self, c: EntityContext<'_>) -> Result<()> { + let mut hint = StepContext { + ent: &c, + player: self.player, + recursion_abort: 0, + } + .aquire_item(self.target) + .err(); + if hint.is_none() { + self.delete_timer -= c.dt; + if self.delete_timer <= 0. { + hint = None + } else { + hint = Some((None, Message::Text("Tutorial finished.".to_string()))); + } + } + + if hint != self.current_hint { + if let Some((position, _)) = self.current_hint.take() { + c.packet_out.push_back(PacketC::ServerHint { + position, + message: None, + }); + } + if let Some((position, message)) = hint.clone() { + c.packet_out.push_back(PacketC::ServerHint { + position, + message: Some(message), + }); + } + self.current_hint = hint; + } + Ok(()) } - fn destructor(&mut self, _c: EntityContext<'_>) {} fn interact( &mut self, _c: EntityContext<'_>, @@ -20,3 +72,163 @@ impl Entity for Tutorial { Ok(false) } } + +struct StepContext<'a> { + ent: &'a EntityContext<'a>, + recursion_abort: usize, + player: PlayerID, +} + +impl<'a> StepContext<'a> { + fn is_hand_item(&self, item: ItemIndex) -> bool { + self.ent + .game + .players + .get(&self.player) + .map_or(false, |p| p.item.as_ref().map_or(false, |i| i.kind == item)) + } + fn find_recipe_with_output(&self, item: ItemIndex) -> Option<RecipeIndex> { + self.ent + .game + .data + .recipes + .iter() + .enumerate() + .find(|(_, r)| r.outputs().contains(&item)) + .map(|(i, _)| RecipeIndex(i)) + } + fn find_item_on_map(&self, item: ItemIndex) -> Option<IVec2> { + self.ent + .game + .tiles + .iter() + .find(|(_, t)| t.item.as_ref().map_or(false, |t| t.kind == item)) + .map(|(p, _)| *p) + } + fn find_tile(&self, tile: TileIndex) -> Option<IVec2> { + self.ent + .game + .tiles + .iter() + .find(|(_, t)| t.kind == tile) + .map(|(p, _)| *p) + } + fn aquire_placed_item(&mut self, item: ItemIndex) -> Result<IVec2, (Option<IVec2>, Message)> { + debug!( + "aquire placed item {:?}", + self.ent.game.data.item_names[item.0] + ); + if let Some(pos) = self.find_item_on_map(item) { + return Ok(pos); + } + self.aquire_item(item)?; + Err((None, Message::Text("put down this item".to_string()))) + } + fn aquire_item(&mut self, item: ItemIndex) -> Result<(), (Option<IVec2>, Message)> { + debug!("aquire item {:?}", self.ent.game.data.item_names[item.0]); + self.recursion_abort += 1; + if self.recursion_abort > 32 { + warn!("too much recursion"); + return Err(( + None, + Message::Text("server cant handle the recipe, too much recursion".to_string()), + )); + } + if self.is_hand_item(item) { + return Ok(()); + } + if let Some(pos) = self.find_item_on_map(item) { + return Err((Some(pos), Message::Text("pickup".to_string()))); + } + if let Some(recipe) = self.find_recipe_with_output(item) { + let r = &self.ent.game.data.recipes[recipe.0]; + match r { + Recipe::Instant { + tile: Some(tile), + inputs: [None, None], + .. + } => { + if let Some(pos) = self.find_tile(*tile) { + return Err((Some(pos), Message::Text("take from crate".to_string()))); + } + } + Recipe::Instant { + tile: None, + inputs: [Some(a), Some(b)], + .. + } => { + let apos = self.aquire_placed_item(*a)?; + self.aquire_item(*b)?; + return Err((Some(apos), Message::Text("interact here".to_string()))); + } + Recipe::Instant { + tile: None, + inputs: [Some(input), None], + .. + } => { + self.aquire_item(*input)?; + return Err((None, Message::Text("interact with empty tile".to_string()))); + } + Recipe::Active { + tile: Some(tile), + input, + speed, + .. + } => { + if let Some(pos) = self.find_tile(*tile) { + if let Some(item) = &self.ent.game.tiles.get(&pos).unwrap().item { + if item.kind == *input { + return Err(( + Some(pos), + Message::Text(format!("hold interact here")), + )); + } else { + return Err((Some(pos), Message::Text(format!("clear tile")))); + } + } + self.aquire_item(*input)?; + return Err(( + Some(pos), + Message::Text(format!("active here for {:.01}s", 1. / speed)), + )); + } + } + Recipe::Passive { + tile: Some(tile), + input, + .. + } => { + if let Some(pos) = self.find_tile(*tile) { + if let Some(item) = &self.ent.game.tiles.get(&pos).unwrap().item { + if item.kind == *input { + return Err((Some(pos), Message::Text(format!("wait for finish")))); + } else { + return Err((Some(pos), Message::Text(format!("clear tile")))); + } + } + self.aquire_item(*input)?; + return Err(( + Some(pos), + Message::Text(format!( + "put on {}", + self.ent.game.data.tile_name(*tile) + )), + )); + } + } + Recipe::Passive { + tile: None, input, .. + } => { + self.aquire_item(*input)?; + return Err((None, Message::Text(format!("wait for finish")))); + } + _ => warn!("recipe too hard {r:?}"), + } + } + warn!( + "stuck at making item {:?}", + self.ent.game.data.item_names[item.0] + ); + Err((None, Message::Text(format!("stuck")))) + } +} diff --git a/server/src/server.rs b/server/src/server.rs index 01460cb9..2259d159 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -670,6 +670,8 @@ impl Server { warn!("Entity tick failed: {e}") } } + self.entities.retain(|e| !e.finished()); + if let Some(map) = load_map { return Some((map, Some(Duration::from_secs(300)))); } diff --git a/test-client/main.ts b/test-client/main.ts index 6ed3c6e1..875e420d 100644 --- a/test-client/main.ts +++ b/test-client/main.ts @@ -97,12 +97,13 @@ export interface MessageData { inner: Message anim_position: V2, anim_size: number, - timeout: MessageTimeout, + timeout?: MessageTimeout, } export const players = new Map<PlayerID, PlayerData>() export const tiles = new Map<string, TileData>() export const items_removed = new Set<ItemData>() +export const server_hints = new Map<string, MessageData>() export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0], tile_collide: [], tile_interact: [], maps: [] } @@ -245,6 +246,12 @@ function packet(p: PacketC) { case "movement_sync": players.get(my_id)!.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 "menu": switch (p.menu) { case "book": open("https://s.metamuffin.org/static/hurrycurry/book.pdf"); break @@ -272,6 +279,8 @@ function keyboard(ev: KeyboardEvent, down: boolean) { 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 bus" } }) + 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) keys_down.add(ev.code) else keys_down.delete(ev.code) @@ -364,6 +373,9 @@ function frame_update(dt: number) { } 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 @@ -399,7 +411,8 @@ function draw() { function tick_message(m: MessageData | undefined, dt: number): boolean { if (!m) return true - m.anim_size = lerp_exp(m.anim_size, m.timeout.remaining > 0.3 ? 1 : 0, dt * 3) + 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 } diff --git a/test-client/protocol.ts b/test-client/protocol.ts index f88d1dec..d0f5f997 100644 --- a/test-client/protocol.ts +++ b/test-client/protocol.ts @@ -1,5 +1,3 @@ -import { V2 } from "./util.ts"; - /* Hurry Curry! - a game about cooking Copyright 2024 metamuffin @@ -60,7 +58,7 @@ export type PacketC = | { type: "update_map", tile: Vec2, kind: TileIndex | null, neighbors: [TileIndex | null] } // A map tile was changed | { type: "communicate", player: PlayerID, message?: Message, timeout?: MessageTimeout } // A player wants to communicate something, message is null when cleared | { type: "server_message", text: string } // Text message from the server - | { type: "server_hint", message?: Message, position?: V2 } // Hint message from server with optional position. Message is unset to clear previous message + | { type: "server_hint", message?: Message, position?: Vec2 } // Hint message from server with optional position. Message is unset to clear previous message | { type: "score" } & Score // Supplies information for score OSD | { type: "menu" } & Menu // Open a menu on the client-side | { type: "environment", effects: string[] } diff --git a/test-client/visual.ts b/test-client/visual.ts index 4c0a8ecc..09b6ec85 100644 --- a/test-client/visual.ts +++ b/test-client/visual.ts @@ -15,7 +15,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { ItemData, MessageData, PlayerData, TileData, camera, camera_scale, canvas, ctx, data, demands_completed, demands_failed, get_interact_target, global_message, interact_active_anim, interact_possible_anim, interact_target_anim, items_removed, keys_down, my_id, nametag_scale_anim, players, points, tiles, time_remaining } from "./main.ts"; +import { ItemData, MessageData, PlayerData, TileData, camera, camera_scale, canvas, ctx, data, demands_completed, demands_failed, get_interact_target, global_message, interact_active_anim, interact_possible_anim, interact_target_anim, items_removed, keys_down, my_id, nametag_scale_anim, players, points, server_hints, tiles, time_remaining } 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"; @@ -71,9 +71,14 @@ export function draw_ingame() { if (player.message_persist) draw_message(player.message_persist) } + // Draw nametags for (const [_, player] of players) draw_player_nametag(player) + // Draw server hints + for (const [_, message] of server_hints) + draw_message(message) + // Draw interact target draw_interact_target() @@ -221,12 +226,14 @@ function draw_message(m: MessageData) { ctx.closePath() ctx.fill() - 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() + 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) |